release: 2026-03-31 (40건 커밋) #118
4
.gitignore
vendored
4
.gitignore
vendored
@ -101,9 +101,7 @@ logs/
|
|||||||
# Frontend (Vite + React)
|
# Frontend (Vite + React)
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/node/
|
frontend/node/
|
||||||
src/main/resources/static/assets/
|
src/main/resources/static/
|
||||||
src/main/resources/static/index.html
|
|
||||||
src/main/resources/static/vite.svg
|
|
||||||
|
|
||||||
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
|
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
@ -4,6 +4,38 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [2026-03-25]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
193
docs/ddl/indicator_column_name_mapping.sql
Normal file
193
docs/ddl/indicator_column_name_mapping.sql
Normal file
@ -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;
|
||||||
@ -2,9 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/x-icon" href="/snp-api/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/snp-api/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/snp-api/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/snp-api/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/snp-api/site.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>S&P 배치 관리</title>
|
<title>S&P Data Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
frontend/public/android-chrome-192x192.png
Normal file
BIN
frontend/public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 10 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
BIN
frontend/public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 24 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 9.4 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
BIN
frontend/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 636 B |
BIN
frontend/public/favicon-32x32.png
Normal file
BIN
frontend/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 1.4 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 15 KiB |
1
frontend/public/site.webmanifest
Normal file
1
frontend/public/site.webmanifest
Normal file
@ -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"}
|
||||||
@ -1,11 +1,12 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
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 { ToastProvider, useToastContext } from './contexts/ToastContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import ToastContainer from './components/Toast';
|
import ToastContainer from './components/Toast';
|
||||||
import LoadingSpinner from './components/LoadingSpinner';
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
const MainMenu = lazy(() => import('./pages/MainMenu'));
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
const Jobs = lazy(() => import('./pages/Jobs'));
|
const Jobs = lazy(() => import('./pages/Jobs'));
|
||||||
const Executions = lazy(() => import('./pages/Executions'));
|
const Executions = lazy(() => import('./pages/Executions'));
|
||||||
@ -14,17 +15,23 @@ const Recollects = lazy(() => import('./pages/Recollects'));
|
|||||||
const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
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 ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
|
||||||
|
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
const { toasts, removeToast } = useToastContext();
|
const { toasts, removeToast } = useToastContext();
|
||||||
|
const location = useLocation();
|
||||||
|
const isMainMenu = location.pathname === '/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-wing-bg text-wing-text">
|
<div className="min-h-screen bg-wing-bg text-wing-text">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className={isMainMenu ? 'px-4' : 'max-w-7xl mx-auto px-4 py-6'}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<MainMenu />} />
|
||||||
|
<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 />} />
|
||||||
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
||||||
@ -32,6 +39,9 @@ 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-config" element={<BypassConfig />} />
|
||||||
|
<Route path="/screening-guide" element={<ScreeningGuide />} />
|
||||||
|
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
109
frontend/src/api/bypassApi.ts
Normal file
109
frontend/src/api/bypassApi.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// API 응답 타입
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
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<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
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<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
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<T>(url: string): Promise<T> {
|
||||||
|
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<ApiResponse<BypassConfigResponse[]>>(BASE),
|
||||||
|
getConfig: (id: number) =>
|
||||||
|
fetchJson<ApiResponse<BypassConfigResponse>>(`${BASE}/${id}`),
|
||||||
|
createConfig: (data: BypassConfigRequest) =>
|
||||||
|
postJson<ApiResponse<BypassConfigResponse>>(BASE, data),
|
||||||
|
updateConfig: (id: number, data: BypassConfigRequest) =>
|
||||||
|
putJson<ApiResponse<BypassConfigResponse>>(`${BASE}/${id}`, data),
|
||||||
|
deleteConfig: (id: number) =>
|
||||||
|
deleteJson<ApiResponse<void>>(`${BASE}/${id}`),
|
||||||
|
generateCode: (id: number, force = false) =>
|
||||||
|
postJson<ApiResponse<CodeGenerationResult>>(`${BASE}/${id}/generate?force=${force}`),
|
||||||
|
getWebclientBeans: () =>
|
||||||
|
fetchJson<ApiResponse<WebClientBeanInfo[]>>(`${BASE}/webclient-beans`),
|
||||||
|
};
|
||||||
148
frontend/src/api/screeningGuideApi.ts
Normal file
148
frontend/src/api/screeningGuideApi.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// API 응답 타입
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
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<T>(url: string): Promise<T> {
|
||||||
|
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<ApiResponse<RiskCategoryResponse[]>>(`${BASE}/risk-indicators?lang=${lang}`),
|
||||||
|
getComplianceIndicators: (lang = 'KO', type = 'SHIP') =>
|
||||||
|
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
|
||||||
|
getMethodologyHistory: (lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
|
||||||
|
getShipRiskHistory: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getShipComplianceHistory: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getCompanyComplianceHistory: (companyCode: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`),
|
||||||
|
getShipInfo: (imoNo: string) =>
|
||||||
|
fetchJson<ApiResponse<ShipInfoResponse>>(`${BASE}/ship-info?imoNo=${imoNo}`),
|
||||||
|
getShipRiskStatus: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-risk-status?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getShipComplianceStatus: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-compliance-status?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getCompanyInfo: (companyCode: string) =>
|
||||||
|
fetchJson<ApiResponse<CompanyInfoResponse>>(`${BASE}/company-info?companyCode=${companyCode}`),
|
||||||
|
getCompanyComplianceStatus: (companyCode: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/company-compliance-status?companyCode=${companyCode}&lang=${lang}`),
|
||||||
|
};
|
||||||
@ -1,32 +1,84 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useThemeContext } from '../contexts/ThemeContext';
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
const navItems = [
|
interface NavSection {
|
||||||
{ path: '/', label: '대시보드', icon: '📊' },
|
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: '/executions', label: '실행 이력', icon: '📋' },
|
||||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
||||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||||
{ path: '/schedule-timeline', 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: '📜' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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() {
|
export default function Navbar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { theme, toggle } = useThemeContext();
|
const { theme, toggle } = useThemeContext();
|
||||||
|
const currentSection = getCurrentSection(location.pathname);
|
||||||
|
|
||||||
|
// 메인 화면에서는 Navbar 숨김
|
||||||
|
if (!currentSection) return null;
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/') return location.pathname === '/';
|
if (path === '/dashboard') return location.pathname === '/dashboard';
|
||||||
return 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">
|
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<Link to="/" className="text-lg font-bold text-wing-accent no-underline">
|
<div className="flex items-center gap-3">
|
||||||
S&P 배치 관리
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="px-2.5 py-1.5 rounded-lg text-sm font-medium no-underline text-wing-muted hover:bg-wing-hover hover:text-wing-accent transition-colors"
|
||||||
|
title="메인 메뉴"
|
||||||
|
>
|
||||||
|
← 메인
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className="text-wing-border">|</span>
|
||||||
|
<span className="text-sm font-bold text-wing-accent">{currentSection.title}</span>
|
||||||
|
</div>
|
||||||
<div className="flex gap-1 flex-wrap items-center">
|
<div className="flex gap-1 flex-wrap items-center">
|
||||||
{navItems.map((item) => (
|
{currentSection.items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
|||||||
222
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
222
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
@ -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<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepNumber = 1 | 2;
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepNumber, string> = {
|
||||||
|
1: '기본 정보',
|
||||||
|
2: '파라미터',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORM: Omit<BypassConfigRequest, 'params'> = {
|
||||||
|
domainName: '',
|
||||||
|
displayName: '',
|
||||||
|
webclientBean: '',
|
||||||
|
externalPath: '',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BypassConfigModal({
|
||||||
|
open,
|
||||||
|
editConfig,
|
||||||
|
webclientBeans,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: BypassConfigModalProps) {
|
||||||
|
const [step, setStep] = useState<StepNumber>(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<BypassParamDto[]>([]);
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl w-full max-w-3xl mx-4 flex flex-col max-h-[90vh]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 pt-6 pb-4 border-b border-wing-border shrink-0">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
{editConfig ? 'Bypass API 수정' : 'Bypass API 등록'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 스텝 인디케이터 */}
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{steps.map((s, idx) => (
|
||||||
|
<div key={s} className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'w-7 h-7 rounded-full flex items-center justify-center text-sm font-semibold transition-colors',
|
||||||
|
step === s
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: step > s
|
||||||
|
? 'bg-wing-accent/30 text-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'text-sm font-medium',
|
||||||
|
step === s ? 'text-wing-text' : 'text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[s]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div className="w-8 h-px bg-wing-border mx-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="px-6 py-5 overflow-y-auto flex-1">
|
||||||
|
{step === 1 && (
|
||||||
|
<BypassStepBasic
|
||||||
|
domainName={domainName}
|
||||||
|
displayName={displayName}
|
||||||
|
webclientBean={webclientBean}
|
||||||
|
externalPath={externalPath}
|
||||||
|
httpMethod={httpMethod}
|
||||||
|
description={description}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
isEdit={editConfig !== null}
|
||||||
|
onChange={handleBasicChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<BypassStepParams params={params} onChange={setParams} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="px-6 py-4 border-t border-wing-border flex justify-between shrink-0">
|
||||||
|
<div>
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s - 1) as StepNumber)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{step === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step < 2 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s + 1) as StepNumber)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
142
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
BYPASS API의 기본 정보를 입력하세요. 도메인명을 기반으로 코드가 생성됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 도메인명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
도메인명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domainName}
|
||||||
|
onChange={(e) => 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(' ')}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-wing-muted">영문 소문자/숫자 조합 (수정 불가)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
표시명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WebClient */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
WebClient Bean <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={webclientBean}
|
||||||
|
onChange={(e) => onChange('webclientBean', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{webclientBeans.map((bean) => (
|
||||||
|
<option key={bean.name} value={bean.name}>
|
||||||
|
{bean.description || bean.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 API 경로 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
외부 API 경로 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={externalPath}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP 메서드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
HTTP 메서드 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['GET', 'POST'].map((method) => (
|
||||||
|
<button
|
||||||
|
key={method}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('httpMethod', method)}
|
||||||
|
className={[
|
||||||
|
'flex-1 py-2 text-sm font-medium rounded-lg border transition-colors',
|
||||||
|
httpMethod === method
|
||||||
|
? 'bg-wing-accent text-white border-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:bg-wing-hover',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="이 API에 대한 설명을 입력하세요"
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
157
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import type { BypassParamDto } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepParamsProps {
|
||||||
|
params: BypassParamDto[];
|
||||||
|
onChange: (params: BypassParamDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAM_TYPES = ['STRING', 'INTEGER', 'LONG', 'BOOLEAN'];
|
||||||
|
const PARAM_IN_OPTIONS = ['PATH', 'QUERY', 'BODY'];
|
||||||
|
|
||||||
|
function createEmptyParam(sortOrder: number): BypassParamDto {
|
||||||
|
return {
|
||||||
|
paramName: '',
|
||||||
|
paramType: 'STRING',
|
||||||
|
paramIn: 'QUERY',
|
||||||
|
required: false,
|
||||||
|
description: '',
|
||||||
|
example: '',
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepParams({ params, onChange }: BypassStepParamsProps) {
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...params, createEmptyParam(params.length)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const updated = params
|
||||||
|
.filter((_, i) => i !== index)
|
||||||
|
.map((p, i) => ({ ...p, sortOrder: i }));
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, field: keyof BypassParamDto, value: string | boolean | number) => {
|
||||||
|
const updated = params.map((p, i) =>
|
||||||
|
i === index ? { ...p, [field]: value } : p,
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
외부 API 호출에 필요한 파라미터를 정의하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{params.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-wing-muted border border-dashed border-wing-border rounded-lg bg-wing-card">
|
||||||
|
파라미터가 없습니다. 추가 버튼을 클릭하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border">
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[120px]">이름</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[110px]">타입</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[100px]">위치</th>
|
||||||
|
<th className="pb-2 text-center font-medium text-wing-muted pr-3 w-14">필수</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3">설명</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[120px]">Example</th>
|
||||||
|
<th className="pb-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{params.map((param, index) => (
|
||||||
|
<tr key={index} className="group">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramName}
|
||||||
|
onChange={(e) => handleChange(index, 'paramName', e.target.value)}
|
||||||
|
placeholder="paramName"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
value={param.paramType}
|
||||||
|
onChange={(e) => handleChange(index, 'paramType', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{PARAM_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
value={param.paramIn}
|
||||||
|
onChange={(e) => handleChange(index, 'paramIn', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{PARAM_IN_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.required}
|
||||||
|
onChange={(e) => handleChange(index, 'required', e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-wing-border text-wing-accent focus:ring-wing-accent/50 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.description}
|
||||||
|
onChange={(e) => handleChange(index, 'description', e.target.value)}
|
||||||
|
placeholder="파라미터 설명"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.example}
|
||||||
|
onChange={(e) => handleChange(index, 'example', e.target.value)}
|
||||||
|
placeholder="예: 9876543"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(index)}
|
||||||
|
className="p-1.5 text-wing-muted hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-wing-accent border border-wing-accent/30 rounded-lg hover:bg-wing-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
파라미터 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
385
frontend/src/components/screening/ComplianceTab.tsx
Normal file
385
frontend/src/components/screening/ComplianceTab.tsx
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
screeningGuideApi,
|
||||||
|
type ComplianceCategoryResponse,
|
||||||
|
type ComplianceIndicatorResponse,
|
||||||
|
} from '../../api/screeningGuideApi';
|
||||||
|
|
||||||
|
interface ComplianceTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'table' | 'card';
|
||||||
|
type IndicatorType = 'SHIP' | 'COMPANY';
|
||||||
|
|
||||||
|
const SHIP_CAT_COLORS: Record<string, string> = {
|
||||||
|
'Sanctions – Ship (US OFAC)': '#1e3a5f',
|
||||||
|
'Sanctions – Ownership (US OFAC)': '#1d4ed8',
|
||||||
|
'Sanctions – Ship (Non-US)': '#065f46',
|
||||||
|
'Sanctions – Ownership (Non-US)': '#0f766e',
|
||||||
|
'Sanctions – FATF': '#6b21a8',
|
||||||
|
'Sanctions – Other': '#991b1b',
|
||||||
|
'Port Calls': '#065f46',
|
||||||
|
'STS Activity': '#0f766e',
|
||||||
|
'Dark Activity': '#374151',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANY_CAT_COLORS: Record<string, string> = {
|
||||||
|
'Company Sanctions (US OFAC)': '#1e3a5f',
|
||||||
|
'Company Sanctions (Non-US)': '#065f46',
|
||||||
|
'Company Compliance': '#6b21a8',
|
||||||
|
'Company Risk': '#92400e',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCatHex(categoryName: string, type: IndicatorType): string {
|
||||||
|
const map = type === 'SHIP' ? SHIP_CAT_COLORS : COMPANY_CAT_COLORS;
|
||||||
|
return map[categoryName] ?? '#374151';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatRow {
|
||||||
|
category: string;
|
||||||
|
indicatorType: string;
|
||||||
|
indicator: ComplianceIndicatorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComplianceTab({ lang }: ComplianceTabProps) {
|
||||||
|
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('전체');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSelectedCategory('전체');
|
||||||
|
screeningGuideApi
|
||||||
|
.getComplianceIndicators(lang, indicatorType)
|
||||||
|
.then((res) => setCategories(res.data ?? []))
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [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();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* SHIP / COMPANY 토글 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIndicatorType('SHIP')}
|
||||||
|
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||||||
|
indicatorType === 'SHIP'
|
||||||
|
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
선박 컴플라이언스 (SHIP)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIndicatorType('COMPANY')}
|
||||||
|
className={`px-5 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||||||
|
indicatorType === 'COMPANY'
|
||||||
|
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
기업 컴플라이언스 (COMPANY)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⏳</div>
|
||||||
|
<div className="text-sm">데이터를 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||||||
|
<strong>데이터 로딩 실패:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{/* 카테고리 요약 카드 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{uniqueCategories.map((catName) => {
|
||||||
|
const count = flatRows.filter((r) => r.category === catName).length;
|
||||||
|
const isActive = selectedCategory === catName;
|
||||||
|
const hex = getCatHex(catName, indicatorType);
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
onClick={() => setSelectedCategory('전체')}
|
||||||
|
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-colors border ${
|
||||||
|
selectedCategory === '전체'
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체 ({flatRows.length})
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
표시:{' '}
|
||||||
|
<strong className="text-wing-text">{filtered.length}</strong>개 항목
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode(viewMode === 'table' ? 'card' : 'table')}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-wing-border bg-wing-card text-wing-muted text-xs font-semibold hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
{viewMode === 'table' ? '📋 카드 보기' : '📊 테이블 보기'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={downloadCSV}
|
||||||
|
className="px-4 py-1.5 rounded-lg bg-green-600 text-white text-xs font-bold hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
⬇ CSV 다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 뷰 */}
|
||||||
|
{viewMode === 'table' && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900 text-white">
|
||||||
|
{[
|
||||||
|
'카테고리',
|
||||||
|
'필드명',
|
||||||
|
'설명',
|
||||||
|
'🔴 RED',
|
||||||
|
'🟡 AMBER',
|
||||||
|
'🟢 GREEN',
|
||||||
|
'데이터 타입',
|
||||||
|
'이력 관리 참고',
|
||||||
|
].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((row, i) => {
|
||||||
|
const showCat =
|
||||||
|
i === 0 ||
|
||||||
|
filtered[i - 1].category !== row.category;
|
||||||
|
const hex = getCatHex(row.category, indicatorType);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.indicator.indicatorId}-${i}`}
|
||||||
|
className="border-b border-wing-border align-top even:bg-wing-card"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5 min-w-[140px]">
|
||||||
|
{showCat && (
|
||||||
|
<span
|
||||||
|
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ background: hex }}
|
||||||
|
>
|
||||||
|
{row.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[160px]">
|
||||||
|
<div className="font-bold text-wing-text">
|
||||||
|
{row.indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
<div className="text-wing-muted text-[10px] mt-0.5">
|
||||||
|
{row.indicator.fieldKey}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
|
||||||
|
{row.indicator.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[130px]">
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
|
||||||
|
{row.indicator.conditionRed}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[130px]">
|
||||||
|
<div className="bg-amber-50 border border-amber-300 rounded px-2 py-1 text-amber-800">
|
||||||
|
{row.indicator.conditionAmber}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[130px]">
|
||||||
|
<div className="bg-green-50 border border-green-300 rounded px-2 py-1 text-green-800">
|
||||||
|
{row.indicator.conditionGreen}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[110px] text-wing-muted">
|
||||||
|
{row.indicator.dataType}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[200px] text-blue-600 leading-relaxed">
|
||||||
|
{row.indicator.collectionNote &&
|
||||||
|
`💡 ${row.indicator.collectionNote}`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 뷰 */}
|
||||||
|
{viewMode === 'card' && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{filtered.map((row, i) => {
|
||||||
|
const hex = getCatHex(row.category, indicatorType);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${row.indicator.indicatorId}-${i}`}
|
||||||
|
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-4 py-2.5 flex justify-between items-center"
|
||||||
|
style={{ background: hex }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-[10px] font-bold">
|
||||||
|
{row.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/75 text-[10px]">
|
||||||
|
{row.indicator.dataType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="font-bold text-sm text-wing-text mb-0.5">
|
||||||
|
{row.indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-wing-muted mb-3">
|
||||||
|
{row.indicator.fieldKey}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-wing-text leading-relaxed mb-3">
|
||||||
|
{row.indicator.description}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div className="bg-red-50 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-red-800 mb-1">
|
||||||
|
🔴 RED
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-red-800">
|
||||||
|
{row.indicator.conditionRed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-amber-800 mb-1">
|
||||||
|
🟡 AMBER
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-amber-800">
|
||||||
|
{row.indicator.conditionAmber}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-green-800 mb-1">
|
||||||
|
🟢 GREEN
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-green-800">
|
||||||
|
{row.indicator.conditionGreen}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{row.indicator.collectionNote && (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 text-[11px] text-blue-700 leading-relaxed">
|
||||||
|
💡 {row.indicator.collectionNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
908
frontend/src/components/screening/HistoryTab.tsx
Normal file
908
frontend/src/components/screening/HistoryTab.tsx
Normal file
@ -0,0 +1,908 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { screeningGuideApi, type ChangeHistoryResponse, type ShipInfoResponse, type CompanyInfoResponse, type IndicatorStatusResponse } from '../../api/screeningGuideApi';
|
||||||
|
|
||||||
|
type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
|
||||||
|
|
||||||
|
interface HistoryTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_TYPES: {
|
||||||
|
key: HistoryType;
|
||||||
|
label: string;
|
||||||
|
searchLabel: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
overallColumn: string | null;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: 'ship-risk',
|
||||||
|
label: '선박 위험지표',
|
||||||
|
searchLabel: 'IMO 번호',
|
||||||
|
searchPlaceholder: 'IMO 번호 : 9672533',
|
||||||
|
overallColumn: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ship-compliance',
|
||||||
|
label: '선박 제재',
|
||||||
|
searchLabel: 'IMO 번호',
|
||||||
|
searchPlaceholder: 'IMO 번호 : 9672533',
|
||||||
|
overallColumn: 'lgl_snths_sanction',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'company-compliance',
|
||||||
|
label: '회사 제재',
|
||||||
|
searchLabel: '회사 코드',
|
||||||
|
searchPlaceholder: '회사 코드 : 1288896',
|
||||||
|
overallColumn: 'company_snths_compliance_status',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface GroupedHistory {
|
||||||
|
lastModifiedDate: string;
|
||||||
|
items: ChangeHistoryResponse[];
|
||||||
|
overallBefore: string | null;
|
||||||
|
overallAfter: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
|
'0': { label: 'All Clear', className: 'bg-green-100 text-green-800 border-green-300' },
|
||||||
|
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||||
|
'2': { label: 'Severe', className: 'bg-red-100 text-red-800 border-red-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
'0': '#22c55e',
|
||||||
|
'1': '#eab308',
|
||||||
|
'2': '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
'0': 'All Clear',
|
||||||
|
'1': 'Warning',
|
||||||
|
'2': 'Severe',
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ value }: { value: string | null }) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const status = STATUS_MAP[value];
|
||||||
|
if (!status) return <span className="text-xs text-wing-muted">{value}</span>;
|
||||||
|
return (
|
||||||
|
<span className={`inline-block rounded-full px-3 py-0.5 text-xs font-bold border ${status.className}`}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFlag(code: string | null | undefined): string {
|
||||||
|
if (!code || code.length < 2) return '';
|
||||||
|
const cc = code.slice(0, 2).toUpperCase();
|
||||||
|
const codePoints = [...cc].map((c) => 0x1F1E6 + c.charCodeAt(0) - 65);
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const color = STATUS_COLORS[value] ?? '#6b7280';
|
||||||
|
const label = narrative || STATUS_LABELS[value] || value;
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-start gap-1.5">
|
||||||
|
<span style={{ color }} className="text-sm leading-tight">●</span>
|
||||||
|
<span className="text-xs text-wing-text leading-relaxed text-left">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPLIANCE_STATUS: Record<string, { label: string; className: string }> = {
|
||||||
|
'0': { label: 'No', className: 'bg-green-100 text-green-800' },
|
||||||
|
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
'2': { label: 'Yes', className: 'bg-red-100 text-red-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRiskLabel(item: IndicatorStatusResponse): string {
|
||||||
|
// IUU Fishing: All Clear -> None recorded
|
||||||
|
if (item.columnName === 'ilgl_fshr_viol' && item.value === '0') return 'None recorded';
|
||||||
|
// Risk Data Maintained: 0 -> Yes, 1 -> Not Maintained
|
||||||
|
if (item.columnName === 'risk_data_maint') {
|
||||||
|
if (item.value === '0') return 'Yes';
|
||||||
|
if (item.value === '1') return 'Not Maintained';
|
||||||
|
}
|
||||||
|
return item.narrative || STATUS_LABELS[item.value ?? ''] || item.value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
|
||||||
|
// Risk Data Maintained 체크: Not Maintained(1)이면 해당 지표만 표시
|
||||||
|
const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint');
|
||||||
|
const isNotMaintained = riskDataMaintained?.value === '1';
|
||||||
|
|
||||||
|
const displayItems = isNotMaintained
|
||||||
|
? items.filter((i) => i.columnName === 'risk_data_maint')
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||||
|
for (const item of displayItems) {
|
||||||
|
const cat = item.category || 'Other';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [displayItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isNotMaintained && (
|
||||||
|
<div className="mb-3 px-3 py-2 bg-yellow-50 border border-yellow-300 rounded-lg text-xs text-yellow-800 font-medium">
|
||||||
|
Risk Data is not maintained for this vessel. Only the maintenance status is shown.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{categories.map(([category, catItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{catItems.map((item) => {
|
||||||
|
const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280';
|
||||||
|
const label = getRiskLabel(item);
|
||||||
|
return (
|
||||||
|
<div key={item.columnName} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
|
||||||
|
<span style={{ color }} className="shrink-0 text-sm">●</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-wing-text text-center bg-wing-surface border border-wing-border"
|
||||||
|
style={{ minWidth: '140px' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박 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 예외 처리
|
||||||
|
function getComplianceLabel(item: IndicatorStatusResponse, isCompany: boolean): string | null {
|
||||||
|
// Parent Company 관련: null -> No Parent
|
||||||
|
if (item.value == null || item.value === '') {
|
||||||
|
if (item.fieldName.includes('Parent Company') || item.fieldName.includes('Parent company')) return 'No Parent';
|
||||||
|
if (isCompany && item.columnName === 'prnt_company_compliance_risk') return 'No Parent';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제외할 컬럼명
|
||||||
|
const SHIP_COMPLIANCE_EXCLUDE = ['lgl_snths_sanction']; // Overall은 토글 헤더에 표시
|
||||||
|
const COMPANY_COMPLIANCE_EXCLUDE = ['company_snths_compliance_status']; // Overall은 토글 헤더에 표시
|
||||||
|
|
||||||
|
function ComplianceStatusItem({ item, isCompany }: { item: IndicatorStatusResponse; isCompany: boolean }) {
|
||||||
|
const overrideLabel = getComplianceLabel(item, isCompany);
|
||||||
|
const status = overrideLabel ? null : COMPLIANCE_STATUS[item.value ?? ''];
|
||||||
|
const displayLabel = overrideLabel || (status ? status.label : (item.value ?? '-'));
|
||||||
|
const displayClass = overrideLabel
|
||||||
|
? 'bg-wing-surface text-wing-muted border border-wing-border'
|
||||||
|
: status ? status.className : 'bg-wing-surface text-wing-muted border border-wing-border';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-center ${displayClass}`}
|
||||||
|
style={{ minWidth: '80px' }}
|
||||||
|
>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResponse[]; isCompany: boolean }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('sanctions');
|
||||||
|
|
||||||
|
// 제외 항목 필터링
|
||||||
|
const excludeList = isCompany ? COMPANY_COMPLIANCE_EXCLUDE : SHIP_COMPLIANCE_EXCLUDE;
|
||||||
|
const filteredItems = items.filter((i) => !excludeList.includes(i.columnName));
|
||||||
|
|
||||||
|
// 회사: 탭 없이 2컬럼 그리드
|
||||||
|
if (isCompany) {
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const cat = item.category || 'Other';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [filteredItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{categories.map(([category, catItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
{categories.length > 1 && (
|
||||||
|
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{catItems.map((item) => (
|
||||||
|
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박: 탭 기반 분류
|
||||||
|
const tabData = useMemo(() => {
|
||||||
|
const result: Record<string, IndicatorStatusResponse[]> = {};
|
||||||
|
for (const tab of SHIP_COMPLIANCE_TABS) {
|
||||||
|
result[tab.key] = filteredItems.filter((i) => tab.match(i.category));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [filteredItems]);
|
||||||
|
|
||||||
|
const currentItems = tabData[activeTab] ?? [];
|
||||||
|
|
||||||
|
// 현재 탭 내 카테고리별 그룹핑
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||||
|
for (const item of currentItems) {
|
||||||
|
const cat = item.category || 'Other';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [currentItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 탭 버튼 */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{SHIP_COMPLIANCE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label} ({tabData[tab.key]?.length ?? 0})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 탭 내용 */}
|
||||||
|
{currentItems.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{categories.map(([category, catItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
{categories.length > 1 && (
|
||||||
|
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{catItems.map((item) => (
|
||||||
|
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-xs text-wing-muted py-4">해당 항목이 없습니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||||
|
const [historyType, setHistoryType] = useState<HistoryType>('ship-risk');
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [cache, setCache] = useState<Record<string, ChangeHistoryResponse[]>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||||
|
const [shipInfo, setShipInfo] = useState<ShipInfoResponse | null>(null);
|
||||||
|
const [companyInfo, setCompanyInfo] = useState<CompanyInfoResponse | null>(null);
|
||||||
|
const [riskStatusCache, setRiskStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
|
||||||
|
const [complianceStatusCache, setComplianceStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['info', 'risk', 'compliance', 'history']));
|
||||||
|
|
||||||
|
const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!;
|
||||||
|
const isRisk = historyType === 'ship-risk';
|
||||||
|
const data = cache[lang] ?? [];
|
||||||
|
const riskStatus = riskStatusCache[lang] ?? [];
|
||||||
|
const complianceStatus = complianceStatusCache[lang] ?? [];
|
||||||
|
|
||||||
|
const grouped: GroupedHistory[] = useMemo(() => {
|
||||||
|
const map = new Map<string, ChangeHistoryResponse[]>();
|
||||||
|
for (const item of data) {
|
||||||
|
const key = item.lastModifiedDate;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([date, items]) => {
|
||||||
|
const overallColumn = currentType.overallColumn;
|
||||||
|
if (overallColumn) {
|
||||||
|
const overallItem = items.find((item) => item.changedColumnName === overallColumn);
|
||||||
|
return {
|
||||||
|
lastModifiedDate: date,
|
||||||
|
items,
|
||||||
|
overallBefore: overallItem?.beforeValue ?? null,
|
||||||
|
overallAfter: overallItem?.afterValue ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { lastModifiedDate: date, items, overallBefore: null, overallAfter: null };
|
||||||
|
});
|
||||||
|
}, [data, currentType.overallColumn]);
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
const trimmed = searchValue.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSearched(true);
|
||||||
|
setExpandedDates(new Set());
|
||||||
|
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
|
||||||
|
|
||||||
|
const isShip = historyType !== 'company-compliance';
|
||||||
|
const showRisk = historyType === 'ship-risk';
|
||||||
|
|
||||||
|
const getHistoryCall = (l: string) =>
|
||||||
|
historyType === 'ship-risk'
|
||||||
|
? screeningGuideApi.getShipRiskHistory(trimmed, l)
|
||||||
|
: historyType === 'ship-compliance'
|
||||||
|
? screeningGuideApi.getShipComplianceHistory(trimmed, l)
|
||||||
|
: screeningGuideApi.getCompanyComplianceHistory(trimmed, l);
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = [
|
||||||
|
getHistoryCall('KO'),
|
||||||
|
getHistoryCall('EN'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isShip) {
|
||||||
|
promises.push(
|
||||||
|
screeningGuideApi.getShipInfo(trimmed),
|
||||||
|
screeningGuideApi.getShipComplianceStatus(trimmed, 'KO'),
|
||||||
|
screeningGuideApi.getShipComplianceStatus(trimmed, 'EN'),
|
||||||
|
);
|
||||||
|
if (showRisk) {
|
||||||
|
promises.push(
|
||||||
|
screeningGuideApi.getShipRiskStatus(trimmed, 'KO'),
|
||||||
|
screeningGuideApi.getShipRiskStatus(trimmed, 'EN'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
promises.push(
|
||||||
|
screeningGuideApi.getCompanyInfo(trimmed),
|
||||||
|
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'KO'),
|
||||||
|
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'EN'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.then((results) => {
|
||||||
|
setCache({ KO: results[0].data ?? [], EN: results[1].data ?? [] });
|
||||||
|
if (isShip) {
|
||||||
|
setShipInfo(results[2].data);
|
||||||
|
setCompanyInfo(null);
|
||||||
|
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
|
||||||
|
if (showRisk) {
|
||||||
|
setRiskStatusCache({ KO: results[5].data ?? [], EN: results[6].data ?? [] });
|
||||||
|
} else {
|
||||||
|
setRiskStatusCache({});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShipInfo(null);
|
||||||
|
setCompanyInfo(results[2].data);
|
||||||
|
setRiskStatusCache({});
|
||||||
|
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeChange(type: HistoryType) {
|
||||||
|
setHistoryType(type);
|
||||||
|
setSearchValue('');
|
||||||
|
setCache({});
|
||||||
|
setError(null);
|
||||||
|
setSearched(false);
|
||||||
|
setExpandedDates(new Set());
|
||||||
|
setShipInfo(null);
|
||||||
|
setCompanyInfo(null);
|
||||||
|
setRiskStatusCache({});
|
||||||
|
setComplianceStatusCache({});
|
||||||
|
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(section: string) {
|
||||||
|
setExpandedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(section)) next.delete(section);
|
||||||
|
else next.add(section);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDate(date: string) {
|
||||||
|
setExpandedDates((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(date)) next.delete(date);
|
||||||
|
else next.add(date);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 이력 유형 선택 */}
|
||||||
|
<div className="flex gap-2 flex-wrap justify-center">
|
||||||
|
{HISTORY_TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => handleTypeChange(t.key)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||||||
|
historyType === t.key
|
||||||
|
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex gap-3 items-center justify-center">
|
||||||
|
<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"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={currentType.searchPlaceholder}
|
||||||
|
className="w-full pl-10 pr-8 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchValue('')}
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!searchValue.trim() || loading}
|
||||||
|
className="px-5 py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? '조회 중...' : '조회'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-4 text-red-800 text-sm">
|
||||||
|
<strong>조회 실패:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과: 3개 섹션 */}
|
||||||
|
{searched && !loading && !error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Section 1: 기본 정보 */}
|
||||||
|
{(shipInfo || companyInfo) && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('info')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('info') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">
|
||||||
|
{shipInfo ? '선박 기본 정보' : '회사 기본 정보'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expandedSections.has('info') && (
|
||||||
|
<div className="border-t border-wing-border px-4 py-4">
|
||||||
|
{shipInfo && (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 좌측: 핵심 식별 정보 */}
|
||||||
|
<div className="min-w-[220px] space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-wing-text">{shipInfo.shipName || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-12">IMO</span>
|
||||||
|
<span className="font-mono font-medium text-wing-text">{shipInfo.imoNo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-12">MMSI</span>
|
||||||
|
<span className="font-mono font-medium text-wing-text">{shipInfo.mmsiNo || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-12">Status</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.shipStatus || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px bg-wing-border" />
|
||||||
|
|
||||||
|
{/* 우측: 스펙 정보 */}
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">국적</span>
|
||||||
|
<span className="font-medium text-wing-text">
|
||||||
|
{countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">선종</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.shipType || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">DWT</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.dwt || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">GT</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.gt || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">건조연도</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.buildYear || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo && (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 좌측: 핵심 식별 정보 */}
|
||||||
|
<div className="min-w-[220px] space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-wing-text">{companyInfo.fullName || '-'}</div>
|
||||||
|
{companyInfo.abbreviation && (
|
||||||
|
<div className="text-xs text-wing-muted">{companyInfo.abbreviation}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">Code</span>
|
||||||
|
<span className="font-mono font-medium text-wing-text">{companyInfo.companyCode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">Status</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.status || '-'}</span>
|
||||||
|
</div>
|
||||||
|
{companyInfo.parentCompanyName && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">모회사</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.parentCompanyName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px bg-wing-border" />
|
||||||
|
|
||||||
|
{/* 우측: 상세 정보 */}
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">등록국가</span>
|
||||||
|
<span className="font-medium text-wing-text">
|
||||||
|
{countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{companyInfo.controlCountry && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">관리국가</span>
|
||||||
|
<span className="font-medium text-wing-text">
|
||||||
|
{countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.foundedDate && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">설립일</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.foundedDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.email && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">이메일</span>
|
||||||
|
<span className="font-medium text-wing-text truncate">{companyInfo.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.phone && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">전화</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.website && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">웹사이트</span>
|
||||||
|
<a
|
||||||
|
href={companyInfo.website.startsWith('http') ? companyInfo.website : `https://${companyInfo.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-blue-600 hover:underline truncate"
|
||||||
|
>
|
||||||
|
{companyInfo.website}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 2: Current Risk Indicators (선박 탭만) */}
|
||||||
|
{riskStatus.length > 0 && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('risk')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('risk') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">Current Risk Indicators</span>
|
||||||
|
</button>
|
||||||
|
{expandedSections.has('risk') && (
|
||||||
|
<div className="border-t border-wing-border px-4 py-4">
|
||||||
|
<RiskStatusGrid items={riskStatus} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 3: Current Compliance (선박 제재/회사 제재 탭만) */}
|
||||||
|
{complianceStatus.length > 0 && !isRisk && (() => {
|
||||||
|
const isCompany = historyType === 'company-compliance';
|
||||||
|
const overallItem = isCompany
|
||||||
|
? complianceStatus.find((i) => i.columnName === 'company_snths_compliance_status')
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('compliance')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('compliance') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">Current Compliance</span>
|
||||||
|
{overallItem && (
|
||||||
|
<StatusBadge value={overallItem.value} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expandedSections.has('compliance') && (
|
||||||
|
<div className="border-t border-wing-border px-4 py-4">
|
||||||
|
<ComplianceStatusGrid items={complianceStatus} isCompany={isCompany} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Section 4: 값 변경 이력 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('history')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('history') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">값 변경 이력</span>
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{grouped.length}개 일시, {data.length}건 변동
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expandedSections.has('history') && (
|
||||||
|
<div className="border-t border-wing-border">
|
||||||
|
{grouped.length > 0 ? (
|
||||||
|
<div className="space-y-2 p-2">
|
||||||
|
{grouped.map((group) => {
|
||||||
|
const isExpanded = expandedDates.has(group.lastModifiedDate);
|
||||||
|
const hasOverall =
|
||||||
|
group.overallBefore != null || group.overallAfter != null;
|
||||||
|
const displayItems = (currentType.overallColumn
|
||||||
|
? group.items.filter(
|
||||||
|
(item) => item.changedColumnName !== currentType.overallColumn,
|
||||||
|
)
|
||||||
|
: [...group.items]
|
||||||
|
).sort((a, b) => (a.sortOrder ?? Infinity) - (b.sortOrder ?? Infinity));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.lastModifiedDate}
|
||||||
|
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDate(group.lastModifiedDate)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={`text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-wing-muted">Change Date :</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">
|
||||||
|
{group.lastModifiedDate}
|
||||||
|
</span>
|
||||||
|
{hasOverall ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StatusBadge value={group.overallBefore} />
|
||||||
|
<span className="text-xs text-wing-muted">→</span>
|
||||||
|
<StatusBadge value={group.overallAfter} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{displayItems.length}건 변동
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasOverall && (
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{displayItems.length}건 변동
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isExpanded && displayItems.length > 0 && (
|
||||||
|
<div className="border-t border-wing-border">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900 dark:bg-slate-700 text-white">
|
||||||
|
<th
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
className="px-4 py-2 text-center font-semibold"
|
||||||
|
>
|
||||||
|
필드명
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2 text-center font-semibold"
|
||||||
|
>
|
||||||
|
이전값
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2 text-center font-semibold"
|
||||||
|
>
|
||||||
|
이후값
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{displayItems.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.rowIndex}
|
||||||
|
className="border-b border-wing-border even:bg-wing-card"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
className="px-4 py-2.5 text-wing-text"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{row.fieldName || row.changedColumnName}
|
||||||
|
</div>
|
||||||
|
{row.fieldName && (
|
||||||
|
<div className="text-wing-muted mt-0.5">
|
||||||
|
{row.changedColumnName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isRisk ? (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<RiskValueCell
|
||||||
|
value={row.afterValue}
|
||||||
|
narrative={row.narrative ?? undefined}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<StatusBadge value={row.beforeValue} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<StatusBadge value={row.afterValue} />
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xl mb-1">📭</div>
|
||||||
|
<div className="text-sm">변경 이력이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 초기 상태 */}
|
||||||
|
{!searched && (
|
||||||
|
<div className="flex items-center justify-center py-16 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-3">🔍</div>
|
||||||
|
<div className="text-sm">{currentType.searchLabel}을(를) 입력하고 조회하세요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/src/components/screening/MethodologyTab.tsx
Normal file
174
frontend/src/components/screening/MethodologyTab.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
|
||||||
|
|
||||||
|
interface MethodologyTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_TYPE_COLORS: Record<string, string> = {
|
||||||
|
Addition: '#065f46',
|
||||||
|
Update: '#1d4ed8',
|
||||||
|
Expansion: '#6b21a8',
|
||||||
|
Change: '#92400e',
|
||||||
|
Removal: '#991b1b',
|
||||||
|
New: '#0f766e',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getChangeTypeColor(changeType: string): string {
|
||||||
|
return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
||||||
|
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState('전체');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
screeningGuideApi
|
||||||
|
.getMethodologyHistory(lang)
|
||||||
|
.then((res) => setHistory(res.data ?? []))
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
const sortedHistory = [...history].sort((a, b) =>
|
||||||
|
b.changeDate.localeCompare(a.changeDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueTypes = Array.from(new Set(history.map((h) => h.changeType)));
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
selectedType === '전체'
|
||||||
|
? sortedHistory
|
||||||
|
: sortedHistory.filter((h) => h.changeType === selectedType);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⏳</div>
|
||||||
|
<div className="text-sm">데이터를 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||||||
|
<strong>데이터 로딩 실패:</strong> {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 주의사항 배너 */}
|
||||||
|
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
|
||||||
|
<strong>이력 관리 주의사항:</strong> 방법론 변경은 선박·기업의 컴플라이언스
|
||||||
|
상태값을 자동으로 변경시킵니다. 상태 변경이 실제 리스크 변화인지, 방법론
|
||||||
|
업데이트 때문인지 반드시 교차 확인해야 합니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 변경 유형 필터 */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedType('전체')}
|
||||||
|
className={`px-3 py-1 rounded-full text-[11px] font-bold transition-colors border ${
|
||||||
|
selectedType === '전체'
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체 ({history.length})
|
||||||
|
</button>
|
||||||
|
{uniqueTypes.map((type) => {
|
||||||
|
const count = history.filter((h) => h.changeType === type).length;
|
||||||
|
const hex = getChangeTypeColor(type);
|
||||||
|
const isActive = selectedType === type;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setSelectedType(isActive ? '전체' : type)}
|
||||||
|
className="px-3 py-1 rounded-full text-[11px] font-bold transition-all border"
|
||||||
|
style={{
|
||||||
|
background: isActive ? hex : undefined,
|
||||||
|
borderColor: isActive ? hex : undefined,
|
||||||
|
color: isActive ? 'white' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-wing-muted">
|
||||||
|
표시: <strong className="text-wing-text">{filtered.length}</strong>건 | 최신순 정렬
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 목록 */}
|
||||||
|
<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">
|
||||||
|
{['날짜', '변경 유형', '제목', '설명', '참고사항'].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 hex = getChangeTypeColor(row.changeType);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.historyId}-${i}`}
|
||||||
|
className="border-b border-wing-border align-top even:bg-wing-card"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-3 min-w-[110px]">
|
||||||
|
<div className="font-bold text-wing-text">
|
||||||
|
{row.changeDate}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 min-w-[110px]">
|
||||||
|
<span
|
||||||
|
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ background: hex }}
|
||||||
|
>
|
||||||
|
{row.changeType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 min-w-[180px] font-semibold text-wing-text leading-relaxed">
|
||||||
|
{row.updateTitle}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
|
||||||
|
{row.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 min-w-[200px] text-blue-600 leading-relaxed">
|
||||||
|
{row.collectionNote && `💡 ${row.collectionNote}`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-wing-muted text-sm">
|
||||||
|
해당 유형의 변경 이력이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
frontend/src/components/screening/RiskTab.tsx
Normal file
349
frontend/src/components/screening/RiskTab.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
|
||||||
|
|
||||||
|
interface RiskTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'table' | 'card';
|
||||||
|
|
||||||
|
const CAT_COLORS: Record<string, string> = {
|
||||||
|
'AIS': 'bg-blue-800',
|
||||||
|
'Port Calls': 'bg-emerald-800',
|
||||||
|
'Associated with Russia': 'bg-red-800',
|
||||||
|
'Behavioural Risk': 'bg-amber-800',
|
||||||
|
'Safety, Security & Inspections': 'bg-blue-600',
|
||||||
|
'Flag Risk': 'bg-purple-800',
|
||||||
|
'Owner & Classification': 'bg-teal-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAT_HEX: Record<string, string> = {
|
||||||
|
'AIS': '#1e40af',
|
||||||
|
'Port Calls': '#065f46',
|
||||||
|
'Associated with Russia': '#991b1b',
|
||||||
|
'Behavioural Risk': '#92400e',
|
||||||
|
'Safety, Security & Inspections': '#1d4ed8',
|
||||||
|
'Flag Risk': '#6b21a8',
|
||||||
|
'Owner & Classification': '#0f766e',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCatColor(categoryName: string): string {
|
||||||
|
return CAT_COLORS[categoryName] ?? 'bg-slate-700';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCatHex(categoryName: string): string {
|
||||||
|
return CAT_HEX[categoryName] ?? '#374151';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatRow {
|
||||||
|
category: string;
|
||||||
|
indicator: RiskIndicatorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RiskTab({ lang }: RiskTabProps) {
|
||||||
|
const [categories, setCategories] = useState<RiskCategoryResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('전체');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
screeningGuideApi
|
||||||
|
.getRiskIndicators(lang)
|
||||||
|
.then((res) => setCategories(res.data ?? []))
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [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();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⏳</div>
|
||||||
|
<div className="text-sm">데이터를 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||||||
|
<strong>데이터 로딩 실패:</strong> {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 카테고리 요약 카드 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 lg:grid-cols-7">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isActive = selectedCategory === cat.categoryName;
|
||||||
|
const hex = getCatHex(cat.categoryName);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.categoryCode}
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedCategory(isActive ? '전체' : cat.categoryName)
|
||||||
|
}
|
||||||
|
className="rounded-lg p-3 text-center cursor-pointer transition-all border-2"
|
||||||
|
style={{
|
||||||
|
background: isActive ? hex : undefined,
|
||||||
|
borderColor: isActive ? hex : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`text-xl font-bold ${isActive ? 'text-white' : 'text-wing-text'}`}
|
||||||
|
>
|
||||||
|
{cat.indicators.length}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs font-semibold leading-tight mt-1 ${isActive ? 'text-white' : 'text-wing-muted'}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
<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);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.indicator.indicatorId}-${i}`}
|
||||||
|
className="border-b border-wing-border align-top even:bg-wing-card"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5 min-w-[110px]">
|
||||||
|
{showCat && (
|
||||||
|
<span
|
||||||
|
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ background: hex }}
|
||||||
|
>
|
||||||
|
{row.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[160px]">
|
||||||
|
<div className="font-bold text-wing-text">
|
||||||
|
{row.indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
<div className="text-wing-muted text-[10px] mt-0.5">
|
||||||
|
{row.indicator.fieldKey}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[220px] leading-relaxed text-wing-text">
|
||||||
|
{row.indicator.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[130px]">
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded px-2 py-1 text-red-800">
|
||||||
|
{row.indicator.conditionRed}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[130px]">
|
||||||
|
<div className="bg-amber-50 border border-amber-300 rounded px-2 py-1 text-amber-800">
|
||||||
|
{row.indicator.conditionAmber}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[130px]">
|
||||||
|
<div className="bg-green-50 border border-green-300 rounded px-2 py-1 text-green-800">
|
||||||
|
{row.indicator.conditionGreen}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[110px] text-wing-muted">
|
||||||
|
{row.indicator.dataType}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 min-w-[200px] text-blue-600 leading-relaxed">
|
||||||
|
{row.indicator.collectionNote &&
|
||||||
|
`💡 ${row.indicator.collectionNote}`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 뷰 */}
|
||||||
|
{viewMode === 'card' && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{filtered.map((row, i) => {
|
||||||
|
const hex = getCatHex(row.category);
|
||||||
|
const colorClass = getCatColor(row.category);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${row.indicator.indicatorId}-${i}`}
|
||||||
|
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${colorClass} px-4 py-2.5 flex justify-between items-center`}
|
||||||
|
style={{ background: hex }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-[10px] font-bold">
|
||||||
|
{row.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/75 text-[10px]">
|
||||||
|
{row.indicator.dataType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="font-bold text-sm text-wing-text mb-0.5">
|
||||||
|
{row.indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-wing-muted mb-3">
|
||||||
|
{row.indicator.fieldKey}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-wing-text leading-relaxed mb-3">
|
||||||
|
{row.indicator.description}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div className="bg-red-50 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-red-800 mb-1">
|
||||||
|
🔴 RED
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-red-800">
|
||||||
|
{row.indicator.conditionRed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-amber-800 mb-1">
|
||||||
|
🟡 AMBER
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-amber-800">
|
||||||
|
{row.indicator.conditionAmber}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-green-800 mb-1">
|
||||||
|
🟢 GREEN
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-green-800">
|
||||||
|
{row.indicator.conditionGreen}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{row.indicator.collectionNote && (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 text-[11px] text-blue-700 leading-relaxed">
|
||||||
|
💡 {row.indicator.collectionNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
507
frontend/src/pages/BypassConfig.tsx
Normal file
507
frontend/src/pages/BypassConfig.tsx
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
bypassApi,
|
||||||
|
type BypassConfigRequest,
|
||||||
|
type BypassConfigResponse,
|
||||||
|
type CodeGenerationResult,
|
||||||
|
type WebClientBeanInfo,
|
||||||
|
} from '../api/bypassApi';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import BypassConfigModal from '../components/bypass/BypassConfigModal';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import InfoModal from '../components/InfoModal';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
interface ConfirmAction {
|
||||||
|
type: 'delete' | 'generate';
|
||||||
|
config: BypassConfigResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
const HTTP_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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BypassConfig() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<BypassConfigResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [webclientBeans, setWebclientBeans] = useState<WebClientBeanInfo[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState('');
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editConfig, setEditConfig] = useState<BypassConfigResponse | null>(null);
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
|
||||||
|
|
||||||
|
const loadConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await bypassApi.getConfigs();
|
||||||
|
setConfigs(res.data ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Bypass API 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
bypassApi.getWebclientBeans()
|
||||||
|
.then((res) => setWebclientBeans(res.data ?? []))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
}, [loadConfigs]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditConfig(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (config: BypassConfigResponse) => {
|
||||||
|
setEditConfig(config);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: BypassConfigRequest) => {
|
||||||
|
if (editConfig) {
|
||||||
|
await bypassApi.updateConfig(editConfig.id, data);
|
||||||
|
showToast('Bypass API가 수정되었습니다.', 'success');
|
||||||
|
} else {
|
||||||
|
await bypassApi.createConfig(data);
|
||||||
|
showToast('Bypass API가 등록되었습니다.', 'success');
|
||||||
|
}
|
||||||
|
await loadConfigs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!confirmAction || confirmAction.type !== 'delete') return;
|
||||||
|
try {
|
||||||
|
await bypassApi.deleteConfig(confirmAction.config.id);
|
||||||
|
showToast('Bypass API가 삭제되었습니다.', 'success');
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('삭제 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setConfirmAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateConfirm = async () => {
|
||||||
|
if (!confirmAction || confirmAction.type !== 'generate') return;
|
||||||
|
const targetConfig = confirmAction.config;
|
||||||
|
setConfirmAction(null);
|
||||||
|
try {
|
||||||
|
const res = await bypassApi.generateCode(targetConfig.id, targetConfig.generated);
|
||||||
|
setGenerationResult(res.data);
|
||||||
|
showToast('코드가 생성되었습니다.', 'success');
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('코드 생성 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainNames = useMemo(() => {
|
||||||
|
const names = [...new Set(configs.map((c) => c.domainName))];
|
||||||
|
return names.sort();
|
||||||
|
}, [configs]);
|
||||||
|
|
||||||
|
const filteredConfigs = useMemo(() => {
|
||||||
|
return configs.filter((c) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.displayName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
|
||||||
|
return matchesSearch && matchesDomain;
|
||||||
|
});
|
||||||
|
}, [configs, searchTerm, selectedDomain]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
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">
|
||||||
|
외부 Maritime API를 직접 프록시하는 Bypass API를 등록하고 코드를 생성합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ 새 API 등록
|
||||||
|
</button>
|
||||||
|
</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
|
||||||
|
type="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
|
||||||
|
type="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
|
||||||
|
type="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">
|
||||||
|
{filteredConfigs.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">등록된 BYPASS API가 없습니다.</p>
|
||||||
|
<p className="text-sm">위 버튼을 눌러 새 API를 등록하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : filteredConfigs.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">
|
||||||
|
{filteredConfigs.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-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
config.generated
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.generated ? '생성 완료' : '미생성'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 정보 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-1.5 py-0.5 text-xs font-bold rounded',
|
||||||
|
HTTP_METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-wing-muted font-mono truncate">
|
||||||
|
{config.externalPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
<span className="font-medium text-wing-text">WebClient:</span>{' '}
|
||||||
|
{config.webclientBean}
|
||||||
|
</p>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 액션 */}
|
||||||
|
<div className="flex gap-2 pt-1 border-t border-wing-border mt-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium text-wing-text bg-wing-surface hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', config })}
|
||||||
|
className="py-1.5 px-3 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
WebClient
|
||||||
|
</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-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">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filteredConfigs.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-2 py-0.5 text-xs font-bold rounded',
|
||||||
|
HTTP_METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{config.webclientBean}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[200px] truncate">
|
||||||
|
{config.externalPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
config.generated
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.generated ? '생성 완료' : '미생성'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
|
{config.createdAt
|
||||||
|
? new Date(config.createdAt).toLocaleDateString('ko-KR')
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', config })}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<BypassConfigModal
|
||||||
|
open={modalOpen}
|
||||||
|
editConfig={editConfig}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmAction?.type === 'delete'}
|
||||||
|
title="삭제 확인"
|
||||||
|
message={`"${confirmAction?.config.displayName}"을(를) 정말 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`}
|
||||||
|
confirmLabel="삭제"
|
||||||
|
confirmColor="bg-red-500 hover:bg-red-600"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 코드 생성 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmAction?.type === 'generate'}
|
||||||
|
title={confirmAction?.config.generated ? '코드 재생성 확인' : '코드 생성 확인'}
|
||||||
|
message={
|
||||||
|
confirmAction?.config.generated
|
||||||
|
? `"${confirmAction?.config.displayName}" 코드를 재생성합니다.\n기존 생성된 파일이 덮어씌워집니다. 계속하시겠습니까?`
|
||||||
|
: `"${confirmAction?.config.displayName}" 코드를 생성합니다.\n계속하시겠습니까?`
|
||||||
|
}
|
||||||
|
confirmLabel={confirmAction?.config.generated ? '재생성' : '생성'}
|
||||||
|
onConfirm={handleGenerateConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 코드 생성 결과 모달 */}
|
||||||
|
<InfoModal
|
||||||
|
open={generationResult !== null}
|
||||||
|
title="코드 생성 완료"
|
||||||
|
onClose={() => setGenerationResult(null)}
|
||||||
|
>
|
||||||
|
{generationResult && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-wing-text">{generationResult.message}</p>
|
||||||
|
<div className="bg-wing-card rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-wing-text mb-1">생성된 파일</p>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">Controller</span>
|
||||||
|
<span className="font-mono text-wing-muted break-all">{generationResult.controllerPath}</span>
|
||||||
|
</div>
|
||||||
|
{generationResult.servicePaths.map((path, idx) => (
|
||||||
|
<div key={`service-${idx}`} className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">
|
||||||
|
Service {generationResult.servicePaths.length > 1 ? idx + 1 : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-wing-muted break-all">{path}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 bg-amber-50 text-amber-700 rounded-lg p-3 text-xs">
|
||||||
|
<span className="shrink-0">⚠</span>
|
||||||
|
<span>서버를 재시작하면 새 API가 활성화됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/pages/MainMenu.tsx
Normal file
69
frontend/src/pages/MainMenu.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: 'S&P Collector',
|
||||||
|
description: 'S&P 배치 수집 관리',
|
||||||
|
detail: '대시보드, 실행 이력, 재수집 이력, 작업 관리, 스케줄, 타임라인',
|
||||||
|
path: '/dashboard',
|
||||||
|
icon: '🔄',
|
||||||
|
iconClass: 'gc-card-icon',
|
||||||
|
menuCount: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'S&P Bypass',
|
||||||
|
description: 'S&P Bypass API 관리',
|
||||||
|
detail: 'API 등록, 코드 생성 관리, 테스트',
|
||||||
|
path: '/bypass-config',
|
||||||
|
icon: '🔗',
|
||||||
|
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||||
|
menuCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'S&P Risk & Compliance',
|
||||||
|
description: 'S&P 위험 지표 및 규정 준수',
|
||||||
|
detail: '위험 지표 및 규정 준수 가이드, 변경 이력 조회',
|
||||||
|
path: '/screening-guide',
|
||||||
|
icon: '⚖️',
|
||||||
|
iconClass: 'gc-card-icon gc-card-icon-nexus',
|
||||||
|
menuCount: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MainMenu() {
|
||||||
|
const { theme, toggle } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[70vh] flex flex-col items-center justify-center">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h1 className="text-3xl font-bold text-wing-text mb-2">S&P Data Platform</h1>
|
||||||
|
<p className="text-sm text-wing-muted">해양 데이터 통합 관리 플랫폼</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 카드 */}
|
||||||
|
<div className="gc-cards">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Link key={section.path} to={section.path} className="gc-card">
|
||||||
|
<div className={section.iconClass}>
|
||||||
|
<span className="text-5xl">{section.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h3>{section.title}</h3>
|
||||||
|
<p>{section.description}<br />{section.detail}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테마 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="mt-8 px-3 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||||
|
hover:text-wing-text border border-wing-border transition-colors"
|
||||||
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️ 라이트 모드' : '🌙 다크 모드'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/pages/RiskComplianceHistory.tsx
Normal file
51
frontend/src/pages/RiskComplianceHistory.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import HistoryTab from '../components/screening/HistoryTab';
|
||||||
|
|
||||||
|
export default function RiskComplianceHistory() {
|
||||||
|
const [lang, setLang] = useState('KO');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
|
||||||
|
<div className="text-xs opacity-75 mb-1">
|
||||||
|
S&P Global · Maritime Intelligence Risk Suite (MIRS)
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold mb-1">
|
||||||
|
Risk & Compliance Change History
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm opacity-85">
|
||||||
|
위험 지표 및 컴플라이언스 값 변경 이력
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 언어 토글 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setLang('EN')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||||
|
lang === 'EN'
|
||||||
|
? 'bg-slate-900 text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLang('KO')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||||
|
lang === 'KO'
|
||||||
|
? 'bg-slate-900 text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
KO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HistoryTab lang={lang} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/pages/ScreeningGuide.tsx
Normal file
100
frontend/src/pages/ScreeningGuide.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import RiskTab from '../components/screening/RiskTab';
|
||||||
|
import ComplianceTab from '../components/screening/ComplianceTab';
|
||||||
|
import MethodologyTab from '../components/screening/MethodologyTab';
|
||||||
|
|
||||||
|
type ActiveTab = 'risk' | 'compliance' | 'methodology';
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ active, onClick, children }: TabButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||||||
|
active
|
||||||
|
? 'bg-slate-900 text-white border-slate-900 shadow-md'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScreeningGuide() {
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>('risk');
|
||||||
|
const [lang, setLang] = useState('KO');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-red-900 rounded-xl p-6 text-white">
|
||||||
|
<div className="text-xs opacity-75 mb-1">
|
||||||
|
S&P Global · Maritime Intelligence Risk Suite (MIRS)
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold mb-1">
|
||||||
|
Risk & Compliance Screening Guide
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm opacity-85">
|
||||||
|
위험 지표 및 컴플라이언스 심사 기준 가이드
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 + 언어 토글 */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'risk'}
|
||||||
|
onClick={() => setActiveTab('risk')}
|
||||||
|
>
|
||||||
|
Risk Indicators
|
||||||
|
</TabButton>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'compliance'}
|
||||||
|
onClick={() => setActiveTab('compliance')}
|
||||||
|
>
|
||||||
|
Compliance
|
||||||
|
</TabButton>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'methodology'}
|
||||||
|
onClick={() => setActiveTab('methodology')}
|
||||||
|
>
|
||||||
|
Methodology History
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 border border-wing-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setLang('EN')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||||
|
lang === 'EN'
|
||||||
|
? 'bg-slate-900 text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLang('KO')}
|
||||||
|
className={`px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||||
|
lang === 'KO'
|
||||||
|
? 'bg-slate-900 text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
KO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 내용 */}
|
||||||
|
{activeTab === 'risk' && <RiskTab lang={lang} />}
|
||||||
|
{activeTab === 'compliance' && <ComplianceTab lang={lang} />}
|
||||||
|
{activeTab === 'methodology' && <MethodologyTab lang={lang} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -23,3 +23,76 @@ body {
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--wing-accent);
|
background: var(--wing-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Main Menu Cards */
|
||||||
|
.gc-cards {
|
||||||
|
padding: 2rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 80%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-cards > * {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border: 1px solid var(--wing-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--wing-surface);
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: inherit !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card:hover {
|
||||||
|
border-color: #4183c4;
|
||||||
|
box-shadow: 0 4px 16px rgba(65, 131, 196, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-icon {
|
||||||
|
color: #4183c4;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-icon-guide {
|
||||||
|
color: #21ba45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-icon-nexus {
|
||||||
|
color: #f2711c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--wing-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--wing-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4183c4;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card:hover .gc-card-link {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.snp.batch.common.web.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseBypassController {
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<ApiResponse<T>> execute(Supplier<T> action) {
|
||||||
|
try {
|
||||||
|
T result = action.get();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
log.error("외부 API 호출 실패 - status: {}, body: {}",
|
||||||
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
return ResponseEntity.status(e.getStatusCode())
|
||||||
|
.body(ApiResponse.error("외부 API 호출 실패: " + e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API 처리 중 오류", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.snp.batch.common.web.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.util.UriBuilder;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseBypassService<T> {
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final String apiPath;
|
||||||
|
private final String displayName;
|
||||||
|
private final ParameterizedTypeReference<List<T>> listTypeRef;
|
||||||
|
private final ParameterizedTypeReference<T> singleTypeRef;
|
||||||
|
|
||||||
|
protected BaseBypassService(WebClient webClient, String apiPath, String displayName,
|
||||||
|
ParameterizedTypeReference<List<T>> listTypeRef,
|
||||||
|
ParameterizedTypeReference<T> singleTypeRef) {
|
||||||
|
this.webClient = webClient;
|
||||||
|
this.apiPath = apiPath;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.listTypeRef = listTypeRef;
|
||||||
|
this.singleTypeRef = singleTypeRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<T> fetchGetList(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출", displayName);
|
||||||
|
List<T> response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(listTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null || response.isEmpty()) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 - 건수: {}", displayName, response.size());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T fetchGetOne(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출 (단건)", displayName);
|
||||||
|
T response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(singleTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 (단건)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<T> fetchPostList(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출", displayName);
|
||||||
|
List<T> response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(listTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null || response.isEmpty()) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 - 건수: {}", displayName, response.size());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T fetchPostOne(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출 (단건)", displayName);
|
||||||
|
T response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(singleTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 (단건)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW GET 요청 → JsonNode 반환 (응답 구조 그대로 패스스루)
|
||||||
|
*/
|
||||||
|
protected JsonNode fetchRawGet(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출 (RAW)", displayName);
|
||||||
|
JsonNode response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.block();
|
||||||
|
log.info("{} API 응답 완료 (RAW)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW POST 요청 → JsonNode 반환 (응답 구조 그대로 패스스루)
|
||||||
|
*/
|
||||||
|
protected JsonNode fetchRawPost(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출 (RAW)", displayName);
|
||||||
|
JsonNode response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.block();
|
||||||
|
log.info("{} API 응답 완료 (RAW)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getApiPath() {
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import io.swagger.v3.oas.models.info.Contact;
|
|||||||
import io.swagger.v3.oas.models.info.Info;
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
import io.swagger.v3.oas.models.info.License;
|
import io.swagger.v3.oas.models.info.License;
|
||||||
import io.swagger.v3.oas.models.servers.Server;
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import org.springdoc.core.models.GroupedOpenApi;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@ -33,6 +34,39 @@ public class SwaggerConfig {
|
|||||||
@Value("${server.servlet.context-path:}")
|
@Value("${server.servlet.context-path:}")
|
||||||
private String contextPath;
|
private String contextPath;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi batchManagementApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("1. Batch Management")
|
||||||
|
.pathsToMatch("/api/batch/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi bypassConfigApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("2. Bypass Config")
|
||||||
|
.pathsToMatch("/api/bypass-config/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi screeningGuideApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("4. Screening Guide")
|
||||||
|
.pathsToMatch("/api/screening-guide/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi bypassApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("3. Bypass API")
|
||||||
|
.pathsToMatch("/api/**")
|
||||||
|
.pathsToExclude("/api/batch/**", "/api/bypass-config/**", "/api/screening-guide/**")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public OpenAPI openAPI() {
|
public OpenAPI openAPI() {
|
||||||
return new OpenAPI()
|
return new OpenAPI()
|
||||||
@ -41,21 +75,12 @@ public class SwaggerConfig {
|
|||||||
new Server()
|
new Server()
|
||||||
.url("http://localhost:" + serverPort + contextPath)
|
.url("http://localhost:" + serverPort + contextPath)
|
||||||
.description("로컬 개발 서버"),
|
.description("로컬 개발 서버"),
|
||||||
new Server()
|
|
||||||
.url("http://10.26.252.39:" + serverPort + contextPath)
|
|
||||||
.description("로컬 개발 서버"),
|
|
||||||
new Server()
|
new Server()
|
||||||
.url("http://211.208.115.83:" + serverPort + contextPath)
|
.url("http://211.208.115.83:" + serverPort + contextPath)
|
||||||
.description("중계 서버"),
|
.description("중계 서버"),
|
||||||
new Server()
|
new Server()
|
||||||
.url("https://guide.gc-si.dev" + contextPath)
|
.url("https://guide.gc-si.dev" + contextPath)
|
||||||
.description("중계 서버 도메인"),
|
.description("GC 도메인")
|
||||||
new Server()
|
|
||||||
.url("http://10.187.58.58:" + serverPort + contextPath)
|
|
||||||
.description("운영 서버"),
|
|
||||||
new Server()
|
|
||||||
.url("https://mda.kcg.go.kr" + contextPath)
|
|
||||||
.description("운영 서버 프록시")
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigRequest;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigResponse;
|
||||||
|
import com.snp.batch.global.dto.CodeGenerationResult;
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import com.snp.batch.global.repository.BypassApiConfigRepository;
|
||||||
|
import com.snp.batch.service.BypassCodeGenerator;
|
||||||
|
import com.snp.batch.service.BypassConfigService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 관리 및 코드 생성 컨트롤러
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/bypass-config")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Bypass Config", description = "BYPASS API 설정 관리 및 코드 생성")
|
||||||
|
public class BypassConfigController {
|
||||||
|
|
||||||
|
private final BypassConfigService bypassConfigService;
|
||||||
|
private final BypassCodeGenerator bypassCodeGenerator;
|
||||||
|
private final BypassApiConfigRepository configRepository;
|
||||||
|
|
||||||
|
@Operation(summary = "설정 목록 조회")
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.getConfigs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 상세 조회")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> getConfig(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.getConfig(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 등록")
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> createConfig(
|
||||||
|
@RequestBody BypassConfigRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.createConfig(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 수정")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> updateConfig(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BypassConfigRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.updateConfig(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 삭제")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteConfig(@PathVariable Long id) {
|
||||||
|
bypassConfigService.deleteConfig(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("삭제 완료", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "코드 생성",
|
||||||
|
description = "등록된 설정의 도메인 전체를 기반으로 Controller, Service 소스 코드를 생성합니다. 같은 도메인의 모든 설정을 하나의 Controller로 합칩니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/{id}/generate")
|
||||||
|
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean force) {
|
||||||
|
try {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
|
||||||
|
List<BypassApiConfig> domainConfigs = configRepository.findByDomainNameOrderById(config.getDomainName());
|
||||||
|
|
||||||
|
CodeGenerationResult result = bypassCodeGenerator.generate(domainConfigs, force);
|
||||||
|
|
||||||
|
domainConfigs.forEach(c -> bypassConfigService.markAsGenerated(c.getId()));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("코드 생성 실패", e);
|
||||||
|
return ResponseEntity.internalServerError().body(ApiResponse.error("코드 생성 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "WebClient 빈 목록", description = "사용 가능한 WebClient 빈 이름 목록을 반환합니다.")
|
||||||
|
@GetMapping("/webclient-beans")
|
||||||
|
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {
|
||||||
|
List<Map<String, String>> beans = List.of(
|
||||||
|
Map.of("name", "maritimeApiWebClient", "description", "Ship API (shipsapi.maritime.spglobal.com)"),
|
||||||
|
Map.of("name", "maritimeAisApiWebClient", "description", "AIS API (aisapi.maritime.spglobal.com)"),
|
||||||
|
Map.of("name", "maritimeServiceApiWebClient", "description", "Web Service API (webservices.maritime.spglobal.com)")
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(beans));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.CompanyInfoResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.IndicatorStatusResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.RiskCategoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.ShipInfoResponse;
|
||||||
|
import com.snp.batch.service.ScreeningGuideService;
|
||||||
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk & Compliance Screening 가이드 조회 컨트롤러
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/screening-guide")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Screening Guide", description = "Risk & Compliance Screening 가이드")
|
||||||
|
public class ScreeningGuideController {
|
||||||
|
|
||||||
|
private final ScreeningGuideService screeningGuideService;
|
||||||
|
|
||||||
|
@Operation(summary = "Risk 지표 목록 조회", description = "카테고리별 Risk 지표 목록을 조회합니다.")
|
||||||
|
@GetMapping("/risk-indicators")
|
||||||
|
public ResponseEntity<ApiResponse<List<RiskCategoryResponse>>> getRiskIndicators(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getRiskIndicators(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Compliance 지표 목록 조회", description = "카테고리별 Compliance 지표 목록을 조회합니다.")
|
||||||
|
@GetMapping("/compliance-indicators")
|
||||||
|
public ResponseEntity<ApiResponse<List<ComplianceCategoryResponse>>> getComplianceIndicators(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang,
|
||||||
|
@Parameter(description = "지표 유형 (SHIP/COMPANY)", example = "SHIP")
|
||||||
|
@RequestParam(defaultValue = "SHIP") String type) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getComplianceIndicators(lang, type)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "방법론 변경 이력 조회", description = "방법론 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/methodology-history")
|
||||||
|
public ResponseEntity<ApiResponse<List<MethodologyHistoryResponse>>> getMethodologyHistory(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/history/ship-risk")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9330019", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskDetailHistory(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 제재 값 변경 이력", description = "IMO 번호로 선박 제재 값 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/history/ship-compliance")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipComplianceHistory(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9330019", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceHistory(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "회사 제재 값 변경 이력", description = "회사 코드로 회사 제재 값 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/history/company-compliance")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getCompanyComplianceHistory(
|
||||||
|
@Parameter(description = "회사 코드", example = "5765290", required = true)
|
||||||
|
@RequestParam String companyCode,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 기본 정보", description = "IMO 번호로 선박 기본 정보를 조회합니다.")
|
||||||
|
@GetMapping("/ship-info")
|
||||||
|
public ResponseEntity<ApiResponse<ShipInfoResponse>> getShipInfo(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||||
|
@RequestParam String imoNo) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipInfo(imoNo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 현재 Risk 지표 상태", description = "IMO 번호로 선박의 현재 Risk 지표 상태를 조회합니다.")
|
||||||
|
@GetMapping("/ship-risk-status")
|
||||||
|
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipRiskStatus(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskStatus(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 현재 Compliance 상태", description = "IMO 번호로 선박의 현재 Compliance 상태를 조회합니다.")
|
||||||
|
@GetMapping("/ship-compliance-status")
|
||||||
|
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipComplianceStatus(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceStatus(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "회사 기본 정보", description = "회사 코드로 회사 기본 정보를 조회합니다.")
|
||||||
|
@GetMapping("/company-info")
|
||||||
|
public ResponseEntity<ApiResponse<CompanyInfoResponse>> getCompanyInfo(
|
||||||
|
@Parameter(description = "회사 코드", example = "1288896", required = true)
|
||||||
|
@RequestParam String companyCode) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyInfo(companyCode)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "회사 현재 Compliance 상태", description = "회사 코드로 회사의 현재 Compliance 상태를 조회합니다.")
|
||||||
|
@GetMapping("/company-compliance-status")
|
||||||
|
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getCompanyComplianceStatus(
|
||||||
|
@Parameter(description = "회사 코드", example = "1288896", required = true)
|
||||||
|
@RequestParam String companyCode,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceStatus(companyCode, lang)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,11 +12,13 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
@Controller
|
@Controller
|
||||||
public class WebViewController {
|
public class WebViewController {
|
||||||
|
|
||||||
@GetMapping({"/", "/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",
|
||||||
"/jobs/**", "/executions/**", "/recollects/**",
|
"/bypass-config", "/screening-guide", "/risk-compliance-history",
|
||||||
"/schedules/**", "/schedule-timeline/**", "/monitoring/**"})
|
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
|
||||||
|
"/schedules/**", "/schedule-timeline/**", "/monitoring/**",
|
||||||
|
"/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"})
|
||||||
public String forward() {
|
public String forward() {
|
||||||
return "forward:/index.html";
|
return "forward:/index.html";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 등록/수정 요청 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassConfigRequest {
|
||||||
|
|
||||||
|
/** 도메인명 (패키지명/URL 경로) */
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 조회 응답 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassConfigResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 도메인명 (패키지명/URL 경로) */
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/** 엔드포인트명 (externalPath 마지막 세그먼트) */
|
||||||
|
private String endpointName;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 코드 생성 완료 여부 */
|
||||||
|
private Boolean generated;
|
||||||
|
|
||||||
|
/** 코드 생성 일시 */
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/** 생성 일시 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** 수정 일시 */
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
}
|
||||||
39
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
39
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 파라미터 정보 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassParamDto {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 파라미터 이름 */
|
||||||
|
private String paramName;
|
||||||
|
|
||||||
|
/** 파라미터 타입 (STRING, INTEGER, LONG, BOOLEAN) */
|
||||||
|
private String paramType;
|
||||||
|
|
||||||
|
/** 파라미터 위치 (PATH, QUERY, BODY) */
|
||||||
|
private String paramIn;
|
||||||
|
|
||||||
|
/** 필수 여부 */
|
||||||
|
private Boolean required;
|
||||||
|
|
||||||
|
/** 파라미터 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 정렬 순서 */
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
/** Swagger @Parameter example 값 */
|
||||||
|
private String example;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 자동 생성 결과 DTO
|
||||||
|
* 같은 도메인에 N개의 엔드포인트를 지원하므로 Service/DTO는 목록으로 반환
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CodeGenerationResult {
|
||||||
|
|
||||||
|
/** 생성된 Controller 파일 경로 */
|
||||||
|
private String controllerPath;
|
||||||
|
|
||||||
|
/** 생성된 Service 파일 경로 목록 (엔드포인트별) */
|
||||||
|
private List<String> servicePaths;
|
||||||
|
|
||||||
|
/** 생성된 DTO 파일 경로 목록 (엔드포인트별) */
|
||||||
|
private List<String> dtoPaths;
|
||||||
|
|
||||||
|
/** 결과 메시지 */
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ChangeHistoryResponse {
|
||||||
|
|
||||||
|
private Long rowIndex;
|
||||||
|
private String searchKey;
|
||||||
|
private String lastModifiedDate;
|
||||||
|
private String changedColumnName;
|
||||||
|
private String beforeValue;
|
||||||
|
private String afterValue;
|
||||||
|
private String fieldName;
|
||||||
|
private String narrative;
|
||||||
|
private String prevNarrative;
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CompanyInfoResponse {
|
||||||
|
private String companyCode;
|
||||||
|
private String fullName;
|
||||||
|
private String abbreviation;
|
||||||
|
private String status;
|
||||||
|
private String parentCompanyCode;
|
||||||
|
private String parentCompanyName;
|
||||||
|
private String registrationCountry;
|
||||||
|
private String registrationCountryCode;
|
||||||
|
private String registrationCountryIsoCode;
|
||||||
|
private String controlCountry;
|
||||||
|
private String controlCountryCode;
|
||||||
|
private String controlCountryIsoCode;
|
||||||
|
private String foundedDate;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String website;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ComplianceCategoryResponse {
|
||||||
|
|
||||||
|
private String category;
|
||||||
|
private String indicatorType;
|
||||||
|
private List<ComplianceIndicatorResponse> indicators;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ComplianceIndicatorResponse {
|
||||||
|
|
||||||
|
private Integer indicatorId;
|
||||||
|
private String fieldKey;
|
||||||
|
private String fieldName;
|
||||||
|
private String description;
|
||||||
|
private String conditionRed;
|
||||||
|
private String conditionAmber;
|
||||||
|
private String conditionGreen;
|
||||||
|
private String dataType;
|
||||||
|
private String collectionNote;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class IndicatorStatusResponse {
|
||||||
|
private String columnName;
|
||||||
|
private String fieldName;
|
||||||
|
private String category;
|
||||||
|
private String value;
|
||||||
|
private String narrative;
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class MethodologyHistoryResponse {
|
||||||
|
|
||||||
|
private Integer historyId;
|
||||||
|
private String changeDate;
|
||||||
|
private String changeType;
|
||||||
|
private String updateTitle;
|
||||||
|
private String description;
|
||||||
|
private String collectionNote;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RiskCategoryResponse {
|
||||||
|
|
||||||
|
private String categoryCode;
|
||||||
|
private String categoryName;
|
||||||
|
private List<RiskIndicatorResponse> indicators;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RiskIndicatorResponse {
|
||||||
|
|
||||||
|
private Integer indicatorId;
|
||||||
|
private String fieldKey;
|
||||||
|
private String fieldName;
|
||||||
|
private String description;
|
||||||
|
private String conditionRed;
|
||||||
|
private String conditionAmber;
|
||||||
|
private String conditionGreen;
|
||||||
|
private String dataType;
|
||||||
|
private String collectionNote;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ShipInfoResponse {
|
||||||
|
private String imoNo;
|
||||||
|
private String shipName;
|
||||||
|
private String shipStatus;
|
||||||
|
private String nationalityCode;
|
||||||
|
private String nationalityIsoCode;
|
||||||
|
private String nationality;
|
||||||
|
private String shipType;
|
||||||
|
private String dwt;
|
||||||
|
private String gt;
|
||||||
|
private String buildYear;
|
||||||
|
private String mmsiNo;
|
||||||
|
private String callSign;
|
||||||
|
private String shipTypeGroup;
|
||||||
|
}
|
||||||
153
src/main/java/com/snp/batch/global/model/BypassApiConfig.java
Normal file
153
src/main/java/com/snp/batch/global/model/BypassApiConfig.java
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 정보를 저장하는 엔티티
|
||||||
|
* 외부 API를 동적으로 프록시하기 위한 설정 메타데이터
|
||||||
|
*
|
||||||
|
* JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "bypass_api_config",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_bypass_config_domain_endpoint", columnNames = {"domain_name", "endpoint_name"})
|
||||||
|
},
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_bypass_config_domain_name", columnList = "domain_name")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassApiConfig {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명 (패키지명/URL 경로)
|
||||||
|
* 예: "ship-info", "port-data"
|
||||||
|
*/
|
||||||
|
@Column(name = "domain_name", nullable = false, length = 50)
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔드포인트명 (externalPath의 마지막 세그먼트에서 자동 추출)
|
||||||
|
* 예: "CompliancesByImos", "CompanyCompliancesByImos"
|
||||||
|
*/
|
||||||
|
@Column(name = "endpoint_name", nullable = false, length = 100)
|
||||||
|
private String endpointName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시명
|
||||||
|
* 예: "선박 정보 API", "항만 데이터 API"
|
||||||
|
*/
|
||||||
|
@Column(name = "display_name", nullable = false, length = 100)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient 빈 이름
|
||||||
|
* 예: "maritimeWebClient", "portWebClient"
|
||||||
|
*/
|
||||||
|
@Column(name = "webclient_bean", nullable = false, length = 100)
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 경로
|
||||||
|
* 예: "/api/v1/ships/{imoNumber}"
|
||||||
|
*/
|
||||||
|
@Column(name = "external_path", nullable = false, length = 500)
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 메서드
|
||||||
|
* 예: "GET", "POST"
|
||||||
|
*/
|
||||||
|
@Column(name = "http_method", nullable = false, length = 10)
|
||||||
|
@Builder.Default
|
||||||
|
private String httpMethod = "GET";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설명
|
||||||
|
*/
|
||||||
|
@Column(name = "description", length = 1000)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성 완료 여부
|
||||||
|
*/
|
||||||
|
@Column(name = "generated", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean generated = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 생성 일시
|
||||||
|
*/
|
||||||
|
@Column(name = "generated_at")
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 일시 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 일시 (감사 필드)
|
||||||
|
*/
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 파라미터 목록
|
||||||
|
*/
|
||||||
|
@OneToMany(mappedBy = "config", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@Builder.Default
|
||||||
|
private List<BypassApiParam> params = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 저장 전 자동 호출 (INSERT 시)
|
||||||
|
* endpointName이 null이면 externalPath에서 자동 추출 (마이그레이션 대응)
|
||||||
|
*/
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
if (this.endpointName == null || this.endpointName.isEmpty()) {
|
||||||
|
this.endpointName = extractEndpointName(this.externalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 업데이트 전 자동 호출 (UPDATE 시)
|
||||||
|
* endpointName이 null이면 externalPath에서 자동 추출 (마이그레이션 대응)
|
||||||
|
*/
|
||||||
|
private static String extractEndpointName(String externalPath) {
|
||||||
|
if (externalPath == null || externalPath.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String[] segments = externalPath.split("/");
|
||||||
|
return segments[segments.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 업데이트 전 자동 호출 (UPDATE 시)
|
||||||
|
*/
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
if (this.endpointName == null || this.endpointName.isEmpty()) {
|
||||||
|
this.endpointName = extractEndpointName(this.externalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/main/java/com/snp/batch/global/model/BypassApiParam.java
Normal file
83
src/main/java/com/snp/batch/global/model/BypassApiParam.java
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 파라미터 정보를 저장하는 엔티티
|
||||||
|
* BypassApiConfig에 종속되며, API 호출 시 사용할 파라미터 메타데이터를 정의
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "bypass_api_param",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"config_id", "param_name"})
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassApiParam {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연관된 BYPASS API 설정
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "config_id", nullable = false)
|
||||||
|
private BypassApiConfig config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 이름
|
||||||
|
* 예: "imoNumber", "mmsi", "startDate"
|
||||||
|
*/
|
||||||
|
@Column(name = "param_name", nullable = false, length = 100)
|
||||||
|
private String paramName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 타입
|
||||||
|
* 예: "STRING", "INTEGER", "LONG", "BOOLEAN"
|
||||||
|
*/
|
||||||
|
@Column(name = "param_type", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String paramType = "STRING";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 위치
|
||||||
|
* 예: "PATH", "QUERY", "BODY"
|
||||||
|
*/
|
||||||
|
@Column(name = "param_in", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String paramIn = "QUERY";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 여부
|
||||||
|
*/
|
||||||
|
@Column(name = "required", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean required = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 설명
|
||||||
|
*/
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 순서
|
||||||
|
*/
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger @Parameter example 값
|
||||||
|
*/
|
||||||
|
@Column(name = "example", length = 200)
|
||||||
|
private String example;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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 = "change_type", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ChangeType {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "type_code", length = 30)
|
||||||
|
private String typeCode;
|
||||||
|
|
||||||
|
@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 = "change_type_lang", schema = "std_snp_data")
|
||||||
|
@IdClass(ChangeTypeLangId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ChangeTypeLang {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "type_code", length = 30)
|
||||||
|
private String typeCode;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "lang_code", length = 5)
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
@Column(name = "type_name", nullable = false, length = 100)
|
||||||
|
private String typeName;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방법론 변경 유형 다국어 복합 PK
|
||||||
|
*/
|
||||||
|
public class ChangeTypeLangId implements Serializable {
|
||||||
|
|
||||||
|
private String typeCode;
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
public ChangeTypeLangId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChangeTypeLangId(String typeCode, String langCode) {
|
||||||
|
this.typeCode = typeCode;
|
||||||
|
this.langCode = langCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof ChangeTypeLangId that)) return false;
|
||||||
|
return Objects.equals(typeCode, that.typeCode) && Objects.equals(langCode, that.langCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(typeCode, langCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 제재 값 변경 이력 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_company_compliance_info_hstry", schema = "std_snp_svc")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class CompanyComplianceHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "row_index")
|
||||||
|
private Long rowIndex;
|
||||||
|
|
||||||
|
@Column(name = "company_cd", nullable = false, length = 14)
|
||||||
|
private String companyCode;
|
||||||
|
|
||||||
|
@Column(name = "last_mdfcn_dt", nullable = false)
|
||||||
|
private LocalDateTime lastModifiedDate;
|
||||||
|
|
||||||
|
@Column(name = "flctn_col_nm", nullable = false, length = 100)
|
||||||
|
private String changedColumnName;
|
||||||
|
|
||||||
|
@Column(name = "bfr_val", length = 4)
|
||||||
|
private String beforeValue;
|
||||||
|
|
||||||
|
@Column(name = "aftr_val", length = 4)
|
||||||
|
private String afterValue;
|
||||||
|
|
||||||
|
@Column(name = "crt_dt", nullable = false)
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_company_dtl_info", schema = "std_snp_svc")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class CompanyDetailInfo {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "company_cd", length = 14)
|
||||||
|
private String companyCode;
|
||||||
|
|
||||||
|
@Column(name = "full_nm", length = 280)
|
||||||
|
private String fullName;
|
||||||
|
|
||||||
|
@Column(name = "company_name_abbr", length = 60)
|
||||||
|
private String abbreviation;
|
||||||
|
|
||||||
|
@Column(name = "country_nm", length = 40)
|
||||||
|
private String country;
|
||||||
|
|
||||||
|
@Column(name = "cty_nm", length = 200)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(name = "company_status", length = 8)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "country_reg", length = 40)
|
||||||
|
private String registrationCountry;
|
||||||
|
|
||||||
|
@Column(name = "oa_addr", length = 512)
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
@Column(name = "prnt_company_cd", length = 14)
|
||||||
|
private String parentCompanyCode;
|
||||||
|
|
||||||
|
@Column(name = "country_reg_cd", length = 6)
|
||||||
|
private String registrationCountryCode;
|
||||||
|
|
||||||
|
@Column(name = "country_ctrl", length = 40)
|
||||||
|
private String controlCountry;
|
||||||
|
|
||||||
|
@Column(name = "country_ctrl_cd", length = 6)
|
||||||
|
private String controlCountryCode;
|
||||||
|
|
||||||
|
@Column(name = "company_fndn_ymd", length = 8)
|
||||||
|
private String foundedDate;
|
||||||
|
|
||||||
|
@Column(name = "eml_addr", length = 320)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "tel", length = 60)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Column(name = "wbst_url", length = 120)
|
||||||
|
private String website;
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴플라이언스 지표 마스터 엔티티 (읽기 전용)
|
||||||
|
* indicator_type: SHIP 또는 COMPANY
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "compliance_indicator", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ComplianceIndicator {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "indicator_id")
|
||||||
|
private Integer indicatorId;
|
||||||
|
|
||||||
|
@Column(name = "indicator_type", nullable = false, length = 20)
|
||||||
|
private String indicatorType;
|
||||||
|
|
||||||
|
@Column(name = "category", nullable = false, length = 100)
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Column(name = "field_key", nullable = false, length = 200)
|
||||||
|
private String fieldKey;
|
||||||
|
|
||||||
|
@Column(name = "data_type_code", length = 50)
|
||||||
|
private String dataTypeCode;
|
||||||
|
|
||||||
|
@Column(name = "collection_note", columnDefinition = "TEXT")
|
||||||
|
private String collectionNote;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Column(name = "column_name", length = 100)
|
||||||
|
private String columnName;
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
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_indicator_lang", schema = "std_snp_data")
|
||||||
|
@IdClass(ComplianceIndicatorLangId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ComplianceIndicatorLang {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "indicator_id")
|
||||||
|
private Integer indicatorId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "lang_code", length = 5)
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
@Column(name = "field_name", nullable = false, length = 500)
|
||||||
|
private String fieldName;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "condition_red", length = 500)
|
||||||
|
private String conditionRed;
|
||||||
|
|
||||||
|
@Column(name = "condition_amber", length = 500)
|
||||||
|
private String conditionAmber;
|
||||||
|
|
||||||
|
@Column(name = "condition_green", length = 500)
|
||||||
|
private String conditionGreen;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴플라이언스 지표 다국어 복합 PK
|
||||||
|
*/
|
||||||
|
public class ComplianceIndicatorLangId implements Serializable {
|
||||||
|
|
||||||
|
private Integer indicatorId;
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
public ComplianceIndicatorLangId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComplianceIndicatorLangId(Integer indicatorId, String langCode) {
|
||||||
|
this.indicatorId = indicatorId;
|
||||||
|
this.langCode = langCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof ComplianceIndicatorLangId that)) return false;
|
||||||
|
return Objects.equals(indicatorId, that.indicatorId) && Objects.equals(langCode, that.langCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(indicatorId, langCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 = "language", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class Language {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "lang_code", length = 5)
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
@Column(name = "lang_name", nullable = false, length = 50)
|
||||||
|
private String langName;
|
||||||
|
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive;
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방법론 변경 이력 마스터 엔티티 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "methodology_history", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class MethodologyHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "history_id")
|
||||||
|
private Integer historyId;
|
||||||
|
|
||||||
|
@Column(name = "change_date", nullable = false)
|
||||||
|
private LocalDate changeDate;
|
||||||
|
|
||||||
|
@Column(name = "change_type_code", nullable = false, length = 30)
|
||||||
|
private String changeTypeCode;
|
||||||
|
|
||||||
|
@Column(name = "collection_note", columnDefinition = "TEXT")
|
||||||
|
private String collectionNote;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
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 = "methodology_history_lang", schema = "std_snp_data")
|
||||||
|
@IdClass(MethodologyHistoryLangId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class MethodologyHistoryLang {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "history_id")
|
||||||
|
private Integer historyId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "lang_code", length = 5)
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
@Column(name = "update_title", length = 500)
|
||||||
|
private String updateTitle;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 방법론 변경 이력 다국어 복합 PK
|
||||||
|
*/
|
||||||
|
public class MethodologyHistoryLangId implements Serializable {
|
||||||
|
|
||||||
|
private Integer historyId;
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
public MethodologyHistoryLangId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public MethodologyHistoryLangId(Integer historyId, String langCode) {
|
||||||
|
this.historyId = historyId;
|
||||||
|
this.langCode = langCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof MethodologyHistoryLangId that)) return false;
|
||||||
|
return Objects.equals(historyId, that.historyId) && Objects.equals(langCode, that.langCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(historyId, langCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위험 지표 마스터 엔티티 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "risk_indicator", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class RiskIndicator {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "indicator_id")
|
||||||
|
private Integer indicatorId;
|
||||||
|
|
||||||
|
@Column(name = "category_code", nullable = false, length = 50)
|
||||||
|
private String categoryCode;
|
||||||
|
|
||||||
|
@Column(name = "field_key", nullable = false, length = 200)
|
||||||
|
private String fieldKey;
|
||||||
|
|
||||||
|
@Column(name = "data_type_code", length = 50)
|
||||||
|
private String dataTypeCode;
|
||||||
|
|
||||||
|
@Column(name = "collection_note", columnDefinition = "TEXT")
|
||||||
|
private String collectionNote;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Column(name = "column_name", length = 100)
|
||||||
|
private String columnName;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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 = "risk_indicator_category", schema = "std_snp_data")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class RiskIndicatorCategory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "category_code", length = 50)
|
||||||
|
private String categoryCode;
|
||||||
|
|
||||||
|
@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 = "risk_indicator_category_lang", schema = "std_snp_data")
|
||||||
|
@IdClass(RiskIndicatorCategoryLangId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class RiskIndicatorCategoryLang {
|
||||||
|
|
||||||
|
@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 RiskIndicatorCategoryLangId implements Serializable {
|
||||||
|
|
||||||
|
private String categoryCode;
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
public RiskIndicatorCategoryLangId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RiskIndicatorCategoryLangId(String categoryCode, String langCode) {
|
||||||
|
this.categoryCode = categoryCode;
|
||||||
|
this.langCode = langCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof RiskIndicatorCategoryLangId that)) return false;
|
||||||
|
return Objects.equals(categoryCode, that.categoryCode) && Objects.equals(langCode, that.langCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(categoryCode, langCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
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 = "risk_indicator_lang", schema = "std_snp_data")
|
||||||
|
@IdClass(RiskIndicatorLangId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class RiskIndicatorLang {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "indicator_id")
|
||||||
|
private Integer indicatorId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "lang_code", length = 5)
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
@Column(name = "field_name", nullable = false, length = 500)
|
||||||
|
private String fieldName;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "condition_red", length = 500)
|
||||||
|
private String conditionRed;
|
||||||
|
|
||||||
|
@Column(name = "condition_amber", length = 500)
|
||||||
|
private String conditionAmber;
|
||||||
|
|
||||||
|
@Column(name = "condition_green", length = 500)
|
||||||
|
private String conditionGreen;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위험 지표 다국어 복합 PK
|
||||||
|
*/
|
||||||
|
public class RiskIndicatorLangId implements Serializable {
|
||||||
|
|
||||||
|
private Integer indicatorId;
|
||||||
|
private String langCode;
|
||||||
|
|
||||||
|
public RiskIndicatorLangId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RiskIndicatorLangId(Integer indicatorId, String langCode) {
|
||||||
|
this.indicatorId = indicatorId;
|
||||||
|
this.langCode = langCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof RiskIndicatorLangId that)) return false;
|
||||||
|
return Objects.equals(indicatorId, that.indicatorId) && Objects.equals(langCode, that.langCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(indicatorId, langCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 제재 값 변경 이력 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_ship_compliance_info_hstry", schema = "std_snp_svc")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ShipComplianceHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "row_index")
|
||||||
|
private Long rowIndex;
|
||||||
|
|
||||||
|
@Column(name = "imo_no", nullable = false, length = 10)
|
||||||
|
private String imoNo;
|
||||||
|
|
||||||
|
@Column(name = "last_mdfcn_dt", nullable = false)
|
||||||
|
private LocalDateTime lastModifiedDate;
|
||||||
|
|
||||||
|
@Column(name = "flctn_col_nm", nullable = false, length = 100)
|
||||||
|
private String changedColumnName;
|
||||||
|
|
||||||
|
@Column(name = "bfr_val", length = 4)
|
||||||
|
private String beforeValue;
|
||||||
|
|
||||||
|
@Column(name = "aftr_val", length = 4)
|
||||||
|
private String afterValue;
|
||||||
|
|
||||||
|
@Column(name = "crt_dt", nullable = false)
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_ship_country_cd", schema = "std_snp_svc")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ShipCountryCode {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "ship_country_cd", length = 3)
|
||||||
|
private String shipCountryCode;
|
||||||
|
|
||||||
|
@Column(name = "cd_nm", length = 100)
|
||||||
|
private String codeName;
|
||||||
|
|
||||||
|
@Column(name = "iso_two_cd", length = 2)
|
||||||
|
private String isoTwoCode;
|
||||||
|
|
||||||
|
@Column(name = "iso_thr_cd", length = 3)
|
||||||
|
private String isoThreeCode;
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_ship_info_mst", schema = "std_snp_svc")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ShipInfoMaster {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "imo_no", length = 10)
|
||||||
|
private String imoNo;
|
||||||
|
|
||||||
|
@Column(name = "ship_nm", length = 75)
|
||||||
|
private String shipName;
|
||||||
|
|
||||||
|
@Column(name = "ship_status", length = 50)
|
||||||
|
private String shipStatus;
|
||||||
|
|
||||||
|
@Column(name = "ntnlty_cd", length = 50)
|
||||||
|
private String nationalityCode;
|
||||||
|
|
||||||
|
@Column(name = "ship_ntnlty", length = 57)
|
||||||
|
private String nationality;
|
||||||
|
|
||||||
|
@Column(name = "ship_type_lv_five", length = 73)
|
||||||
|
private String shipType;
|
||||||
|
|
||||||
|
@Column(name = "dwt", length = 50)
|
||||||
|
private String dwt;
|
||||||
|
|
||||||
|
@Column(name = "gt", length = 50)
|
||||||
|
private String gt;
|
||||||
|
|
||||||
|
@Column(name = "build_yy", length = 50)
|
||||||
|
private String buildYear;
|
||||||
|
|
||||||
|
@Column(name = "mmsi_no", length = 50)
|
||||||
|
private String mmsiNo;
|
||||||
|
|
||||||
|
@Column(name = "clsgn_no", length = 50)
|
||||||
|
private String callSign;
|
||||||
|
|
||||||
|
@Column(name = "ship_type_lv_two", length = 50)
|
||||||
|
private String shipTypeGroup;
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.snp.batch.global.model.screening;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 위험지표 값 변경 이력 (읽기 전용)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tb_ship_risk_detail_info_hstry", schema = "std_snp_svc")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ShipRiskDetailHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "row_index")
|
||||||
|
private Long rowIndex;
|
||||||
|
|
||||||
|
@Column(name = "imo_no", nullable = false, length = 10)
|
||||||
|
private String imoNo;
|
||||||
|
|
||||||
|
@Column(name = "last_mdfcn_dt", nullable = false)
|
||||||
|
private LocalDateTime lastModifiedDate;
|
||||||
|
|
||||||
|
@Column(name = "flctn_col_nm", nullable = false, length = 100)
|
||||||
|
private String changedColumnName;
|
||||||
|
|
||||||
|
@Column(name = "bfr_val", length = 4)
|
||||||
|
private String beforeValue;
|
||||||
|
|
||||||
|
@Column(name = "aftr_val", length = 4)
|
||||||
|
private String afterValue;
|
||||||
|
|
||||||
|
@Column(name = "crt_dt", nullable = false)
|
||||||
|
private LocalDateTime createdDate;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BypassApiConfig Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BypassApiConfigRepository extends JpaRepository<BypassApiConfig, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명으로 BYPASS API 설정 단건 조회 (하위 호환)
|
||||||
|
*/
|
||||||
|
Optional<BypassApiConfig> findByDomainName(String domainName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명으로 BYPASS API 설정 목록 조회 (ID 순)
|
||||||
|
*/
|
||||||
|
List<BypassApiConfig> findByDomainNameOrderById(String domainName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도메인명 + 엔드포인트명 복합 유니크 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByDomainNameAndEndpointName(String domainName, String endpointName);
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BypassApiParam;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BypassApiParam Repository
|
||||||
|
* JPA Repository 방식으로 자동 구현
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BypassApiParamRepository extends JpaRepository<BypassApiParam, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 ID로 파라미터 목록 조회 (정렬 순서 기준 오름차순)
|
||||||
|
*/
|
||||||
|
List<BypassApiParam> findByConfigIdOrderBySortOrder(Long configId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 ID로 파라미터 전체 삭제
|
||||||
|
*/
|
||||||
|
void deleteByConfigId(Long configId);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ChangeTypeLang;
|
||||||
|
import com.snp.batch.global.model.screening.ChangeTypeLangId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ChangeTypeLangRepository extends JpaRepository<ChangeTypeLang, ChangeTypeLangId> {
|
||||||
|
|
||||||
|
List<ChangeTypeLang> findByLangCode(String langCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.CompanyComplianceHistory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CompanyComplianceHistoryRepository extends JpaRepository<CompanyComplianceHistory, Long> {
|
||||||
|
List<CompanyComplianceHistory> findByCompanyCodeOrderByLastModifiedDateDesc(String companyCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.CompanyDetailInfo;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CompanyDetailInfoRepository extends JpaRepository<CompanyDetailInfo, String> {
|
||||||
|
Optional<CompanyDetailInfo> findByCompanyCode(String companyCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ComplianceIndicatorLang;
|
||||||
|
import com.snp.batch.global.model.screening.ComplianceIndicatorLangId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ComplianceIndicatorLangRepository extends JpaRepository<ComplianceIndicatorLang, ComplianceIndicatorLangId> {
|
||||||
|
|
||||||
|
List<ComplianceIndicatorLang> findByLangCode(String langCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ComplianceIndicator;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ComplianceIndicatorRepository extends JpaRepository<ComplianceIndicator, Integer> {
|
||||||
|
|
||||||
|
List<ComplianceIndicator> findByIndicatorTypeOrderBySortOrderAsc(String indicatorType);
|
||||||
|
|
||||||
|
List<ComplianceIndicator> findAllByOrderBySortOrderAsc();
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.MethodologyHistoryLang;
|
||||||
|
import com.snp.batch.global.model.screening.MethodologyHistoryLangId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface MethodologyHistoryLangRepository extends JpaRepository<MethodologyHistoryLang, MethodologyHistoryLangId> {
|
||||||
|
|
||||||
|
List<MethodologyHistoryLang> findByLangCode(String langCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.MethodologyHistory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface MethodologyHistoryRepository extends JpaRepository<MethodologyHistory, Integer> {
|
||||||
|
|
||||||
|
List<MethodologyHistory> findAllByOrderByChangeDateDescSortOrderAsc();
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.RiskIndicatorCategoryLang;
|
||||||
|
import com.snp.batch.global.model.screening.RiskIndicatorCategoryLangId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RiskIndicatorCategoryLangRepository extends JpaRepository<RiskIndicatorCategoryLang, RiskIndicatorCategoryLangId> {
|
||||||
|
|
||||||
|
List<RiskIndicatorCategoryLang> findByLangCode(String langCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.RiskIndicatorLang;
|
||||||
|
import com.snp.batch.global.model.screening.RiskIndicatorLangId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RiskIndicatorLangRepository extends JpaRepository<RiskIndicatorLang, RiskIndicatorLangId> {
|
||||||
|
|
||||||
|
List<RiskIndicatorLang> findByLangCodeOrderByIndicatorIdAsc(String langCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.RiskIndicator;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RiskIndicatorRepository extends JpaRepository<RiskIndicator, Integer> {
|
||||||
|
|
||||||
|
List<RiskIndicator> findAllByOrderByCategoryCodeAscSortOrderAsc();
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ShipComplianceHistory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ShipComplianceHistoryRepository extends JpaRepository<ShipComplianceHistory, Long> {
|
||||||
|
List<ShipComplianceHistory> findByImoNoOrderByLastModifiedDateDesc(String imoNo);
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ShipCountryCode;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ShipCountryCodeRepository extends JpaRepository<ShipCountryCode, String> {
|
||||||
|
Optional<ShipCountryCode> findByShipCountryCode(String shipCountryCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ShipInfoMaster;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ShipInfoMasterRepository extends JpaRepository<ShipInfoMaster, String> {
|
||||||
|
Optional<ShipInfoMaster> findByImoNo(String imoNo);
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package com.snp.batch.global.repository.screening;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.screening.ShipRiskDetailHistory;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ShipRiskDetailHistoryRepository extends JpaRepository<ShipRiskDetailHistory, Long> {
|
||||||
|
|
||||||
|
@Query(nativeQuery = true, value = """
|
||||||
|
SELECT h.row_index, h.imo_no, h.last_mdfcn_dt, h.flctn_col_nm, h.bfr_val, h.aftr_val,
|
||||||
|
CASE h.flctn_col_nm
|
||||||
|
WHEN 'ais_notrcv_elps_days' THEN d.ais_notrcv_elps_days_desc
|
||||||
|
WHEN 'ais_lwrnk_days' THEN d.ais_lwrnk_days_desc
|
||||||
|
WHEN 'ais_up_imo_desc' THEN d.ais_up_imo_desc_val
|
||||||
|
WHEN 'othr_ship_nm_voy_yn' THEN d.othr_ship_nm_voy_yn_desc
|
||||||
|
WHEN 'mmsi_anom_message' THEN d.mmsi_anom_message_desc
|
||||||
|
WHEN 'recent_dark_actv' THEN d.recent_dark_actv_desc
|
||||||
|
WHEN 'port_prtcll' THEN d.port_prtcll_desc
|
||||||
|
WHEN 'port_risk' THEN d.port_risk_desc
|
||||||
|
WHEN 'sts_job' THEN d.sts_job_desc
|
||||||
|
WHEN 'drift_chg' THEN d.drift_chg_desc
|
||||||
|
WHEN 'risk_event' THEN d.risk_event_desc
|
||||||
|
WHEN 'ntnlty_chg' THEN d.ntnlty_chg_desc
|
||||||
|
WHEN 'ntnlty_prs_mou_perf' THEN d.ntnlty_prs_mou_perf_desc
|
||||||
|
WHEN 'ntnlty_tky_mou_perf' THEN d.ntnlty_tky_mou_perf_desc
|
||||||
|
WHEN 'ntnlty_uscg_mou_perf' THEN d.ntnlty_uscg_mou_perf_desc
|
||||||
|
WHEN 'uscg_excl_ship_cert' THEN d.uscg_excl_ship_cert_desc
|
||||||
|
WHEN 'psc_inspection_elps_hr' THEN d.psc_inspection_elps_hr_desc
|
||||||
|
WHEN 'psc_inspection' THEN d.psc_inspection_desc
|
||||||
|
WHEN 'psc_defect' THEN d.psc_defect_desc
|
||||||
|
WHEN 'psc_detained' THEN d.psc_detained_desc
|
||||||
|
WHEN 'now_smgrc_evdc' THEN d.now_smgrc_evdc_desc
|
||||||
|
WHEN 'docc_chg' THEN d.docc_chg_desc
|
||||||
|
WHEN 'now_clfic' THEN d.now_clfic_desc
|
||||||
|
WHEN 'clfic_status_chg' THEN d.clfic_status_chg_desc
|
||||||
|
WHEN 'pni_insrnc' THEN d.pni_insrnc_desc
|
||||||
|
WHEN 'ship_nm_chg' THEN d.ship_nm_chg_desc
|
||||||
|
WHEN 'gbo_chg' THEN d.gbo_chg_desc
|
||||||
|
WHEN 'vslage' THEN d.vslage_desc
|
||||||
|
WHEN 'ilgl_fshr_viol' THEN d.ilgl_fshr_viol_desc
|
||||||
|
WHEN 'draft_chg' THEN d.draft_chg_desc
|
||||||
|
WHEN 'recent_sanction_prtcll' THEN d.recent_sanction_prtcll_desc
|
||||||
|
WHEN 'sngl_ship_voy' THEN d.sngl_ship_voy_desc
|
||||||
|
WHEN 'fltsfty' THEN d.fltsfty_desc
|
||||||
|
WHEN 'flt_psc' THEN d.flt_psc_desc
|
||||||
|
WHEN 'spc_inspection_ovdue' THEN d.spc_inspection_ovdue_desc
|
||||||
|
WHEN 'ownr_unk' THEN d.ownr_unk_desc
|
||||||
|
WHEN 'rss_port_call' THEN d.rss_port_call_desc
|
||||||
|
WHEN 'rss_ownr_reg' THEN d.rss_ownr_reg_desc
|
||||||
|
WHEN 'rss_sts' THEN d.rss_sts_desc
|
||||||
|
END as narrative,
|
||||||
|
CASE h.flctn_col_nm
|
||||||
|
WHEN 'ais_notrcv_elps_days' THEN p.ais_notrcv_elps_days_desc
|
||||||
|
WHEN 'ais_lwrnk_days' THEN p.ais_lwrnk_days_desc
|
||||||
|
WHEN 'ais_up_imo_desc' THEN p.ais_up_imo_desc_val
|
||||||
|
WHEN 'othr_ship_nm_voy_yn' THEN p.othr_ship_nm_voy_yn_desc
|
||||||
|
WHEN 'mmsi_anom_message' THEN p.mmsi_anom_message_desc
|
||||||
|
WHEN 'recent_dark_actv' THEN p.recent_dark_actv_desc
|
||||||
|
WHEN 'port_prtcll' THEN p.port_prtcll_desc
|
||||||
|
WHEN 'port_risk' THEN p.port_risk_desc
|
||||||
|
WHEN 'sts_job' THEN p.sts_job_desc
|
||||||
|
WHEN 'drift_chg' THEN p.drift_chg_desc
|
||||||
|
WHEN 'risk_event' THEN p.risk_event_desc
|
||||||
|
WHEN 'ntnlty_chg' THEN p.ntnlty_chg_desc
|
||||||
|
WHEN 'ntnlty_prs_mou_perf' THEN p.ntnlty_prs_mou_perf_desc
|
||||||
|
WHEN 'ntnlty_tky_mou_perf' THEN p.ntnlty_tky_mou_perf_desc
|
||||||
|
WHEN 'ntnlty_uscg_mou_perf' THEN p.ntnlty_uscg_mou_perf_desc
|
||||||
|
WHEN 'uscg_excl_ship_cert' THEN p.uscg_excl_ship_cert_desc
|
||||||
|
WHEN 'psc_inspection_elps_hr' THEN p.psc_inspection_elps_hr_desc
|
||||||
|
WHEN 'psc_inspection' THEN p.psc_inspection_desc
|
||||||
|
WHEN 'psc_defect' THEN p.psc_defect_desc
|
||||||
|
WHEN 'psc_detained' THEN p.psc_detained_desc
|
||||||
|
WHEN 'now_smgrc_evdc' THEN p.now_smgrc_evdc_desc
|
||||||
|
WHEN 'docc_chg' THEN p.docc_chg_desc
|
||||||
|
WHEN 'now_clfic' THEN p.now_clfic_desc
|
||||||
|
WHEN 'clfic_status_chg' THEN p.clfic_status_chg_desc
|
||||||
|
WHEN 'pni_insrnc' THEN p.pni_insrnc_desc
|
||||||
|
WHEN 'ship_nm_chg' THEN p.ship_nm_chg_desc
|
||||||
|
WHEN 'gbo_chg' THEN p.gbo_chg_desc
|
||||||
|
WHEN 'vslage' THEN p.vslage_desc
|
||||||
|
WHEN 'ilgl_fshr_viol' THEN p.ilgl_fshr_viol_desc
|
||||||
|
WHEN 'draft_chg' THEN p.draft_chg_desc
|
||||||
|
WHEN 'recent_sanction_prtcll' THEN p.recent_sanction_prtcll_desc
|
||||||
|
WHEN 'sngl_ship_voy' THEN p.sngl_ship_voy_desc
|
||||||
|
WHEN 'fltsfty' THEN p.fltsfty_desc
|
||||||
|
WHEN 'flt_psc' THEN p.flt_psc_desc
|
||||||
|
WHEN 'spc_inspection_ovdue' THEN p.spc_inspection_ovdue_desc
|
||||||
|
WHEN 'ownr_unk' THEN p.ownr_unk_desc
|
||||||
|
WHEN 'rss_port_call' THEN p.rss_port_call_desc
|
||||||
|
WHEN 'rss_ownr_reg' THEN p.rss_ownr_reg_desc
|
||||||
|
WHEN 'rss_sts' THEN p.rss_sts_desc
|
||||||
|
END as prev_narrative
|
||||||
|
FROM std_snp_svc.tb_ship_risk_detail_info_hstry h
|
||||||
|
LEFT JOIN std_snp_svc.tb_ship_risk_detail_hstry d
|
||||||
|
ON h.imo_no = d.imo_no AND h.last_mdfcn_dt = d.last_mdfcn_dt
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT *
|
||||||
|
FROM std_snp_svc.tb_ship_risk_detail_hstry prev
|
||||||
|
WHERE prev.imo_no = h.imo_no
|
||||||
|
AND prev.last_mdfcn_dt < h.last_mdfcn_dt
|
||||||
|
ORDER BY prev.last_mdfcn_dt DESC
|
||||||
|
LIMIT 1
|
||||||
|
) p ON true
|
||||||
|
WHERE h.imo_no = :imoNo
|
||||||
|
ORDER BY h.last_mdfcn_dt DESC
|
||||||
|
""")
|
||||||
|
List<Object[]> findRiskHistoryWithNarrative(@Param("imoNo") String imoNo);
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
package com.snp.batch.jobs.common.batch.config;
|
package com.snp.batch.jobs.batch.common.config;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
|
import com.snp.batch.jobs.batch.common.dto.FlagCodeDto;
|
||||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity;
|
||||||
import com.snp.batch.jobs.common.batch.processor.FlagCodeDataProcessor;
|
import com.snp.batch.jobs.batch.common.processor.FlagCodeDataProcessor;
|
||||||
import com.snp.batch.jobs.common.batch.reader.FlagCodeDataReader;
|
import com.snp.batch.jobs.batch.common.reader.FlagCodeDataReader;
|
||||||
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
|
import com.snp.batch.jobs.batch.common.repository.FlagCodeRepository;
|
||||||
import com.snp.batch.jobs.common.batch.writer.FlagCodeDataWriter;
|
import com.snp.batch.jobs.batch.common.writer.FlagCodeDataWriter;
|
||||||
import com.snp.batch.jobs.facility.batch.reader.PortDataReader;
|
import com.snp.batch.jobs.batch.facility.reader.PortDataReader;
|
||||||
import com.snp.batch.service.BatchApiLogService;
|
import com.snp.batch.service.BatchApiLogService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.Job;
|
import org.springframework.batch.core.Job;
|
||||||
@ -1,12 +1,12 @@
|
|||||||
package com.snp.batch.jobs.common.batch.config;
|
package com.snp.batch.jobs.batch.common.config;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
|
import com.snp.batch.jobs.batch.common.dto.Stat5CodeDto;
|
||||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity;
|
||||||
import com.snp.batch.jobs.common.batch.processor.Stat5CodeDataProcessor;
|
import com.snp.batch.jobs.batch.common.processor.Stat5CodeDataProcessor;
|
||||||
import com.snp.batch.jobs.common.batch.reader.Stat5CodeDataReader;
|
import com.snp.batch.jobs.batch.common.reader.Stat5CodeDataReader;
|
||||||
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
|
import com.snp.batch.jobs.batch.common.repository.Stat5CodeRepository;
|
||||||
import com.snp.batch.jobs.common.batch.writer.Stat5CodeDataWriter;
|
import com.snp.batch.jobs.batch.common.writer.Stat5CodeDataWriter;
|
||||||
import com.snp.batch.service.BatchApiLogService;
|
import com.snp.batch.service.BatchApiLogService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.Job;
|
import org.springframework.batch.core.Job;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.snp.batch.jobs.common.batch.dto;
|
package com.snp.batch.jobs.batch.common.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.snp.batch.jobs.common.batch.dto;
|
package com.snp.batch.jobs.batch.common.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.snp.batch.jobs.common.batch.dto;
|
package com.snp.batch.jobs.batch.common.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.snp.batch.jobs.common.batch.dto;
|
package com.snp.batch.jobs.batch.common.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.snp.batch.jobs.common.batch.entity;
|
package com.snp.batch.jobs.batch.common.entity;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.snp.batch.jobs.common.batch.entity;
|
package com.snp.batch.jobs.batch.common.entity;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.snp.batch.jobs.common.batch.processor;
|
package com.snp.batch.jobs.batch.common.processor;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
|
import com.snp.batch.jobs.batch.common.dto.FlagCodeDto;
|
||||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.snp.batch.jobs.common.batch.processor;
|
package com.snp.batch.jobs.batch.common.processor;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
|
import com.snp.batch.jobs.batch.common.dto.Stat5CodeDto;
|
||||||
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
|
import com.snp.batch.jobs.batch.common.entity.Stat5CodeEntity;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.snp.batch.jobs.common.batch.reader;
|
package com.snp.batch.jobs.batch.common.reader;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeApiResponse;
|
import com.snp.batch.jobs.batch.common.dto.FlagCodeApiResponse;
|
||||||
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
|
import com.snp.batch.jobs.batch.common.dto.FlagCodeDto;
|
||||||
import com.snp.batch.service.BatchApiLogService;
|
import com.snp.batch.service.BatchApiLogService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.snp.batch.jobs.common.batch.reader;
|
package com.snp.batch.jobs.batch.common.reader;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeApiResponse;
|
import com.snp.batch.jobs.batch.common.dto.Stat5CodeApiResponse;
|
||||||
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
|
import com.snp.batch.jobs.batch.common.dto.Stat5CodeDto;
|
||||||
import com.snp.batch.service.BatchApiLogService;
|
import com.snp.batch.service.BatchApiLogService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.snp.batch.jobs.common.batch.repository;
|
package com.snp.batch.jobs.batch.common.repository;
|
||||||
|
|
||||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package com.snp.batch.jobs.common.batch.repository;
|
package com.snp.batch.jobs.batch.common.repository;
|
||||||
|
|
||||||
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
|
||||||
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
|
import com.snp.batch.jobs.batch.common.entity.FlagCodeEntity;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user