diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3b9abec..46726ab 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,20 @@ ## [Unreleased] +## [2026-04-02] + +### 추가 +- Risk & Compliance 사용자 편의성 개선 (#134) + - UI 고정 텍스트 다국어 지원 (EN/KO 언어 토글 연동) + - -999/null 값 'No Data'/'데이터 없음' 표시 처리 + - Screening Guide 탭 분리 (Ship Compliance / Company Compliance) + - Change History ↔ Screening Guide 간 언어 설정 공유 (localStorage) + - 섹션 헤더에 Screening Guide 연결 링크 추가 +- 배포 환경에 따른 Swagger 페이지 노출 제한 (#135) + - prod 환경에서 Bypass API 그룹만 노출 + - 그룹별 개별 API 설명 추가 + - prod 환경 서버 목록 GC 도메인만 표시 + ## [2026-04-01] ### 추가 diff --git a/frontend/src/components/screening/ComplianceTab.tsx b/frontend/src/components/screening/ComplianceTab.tsx index 2fa8635..f443d90 100644 --- a/frontend/src/components/screening/ComplianceTab.tsx +++ b/frontend/src/components/screening/ComplianceTab.tsx @@ -4,9 +4,11 @@ import { type ComplianceCategoryResponse, type ComplianceIndicatorResponse, } from '../../api/screeningGuideApi'; +import { t } from '../../constants/screeningTexts'; interface ComplianceTabProps { lang: string; + indicatorType?: 'SHIP' | 'COMPANY'; } type IndicatorType = 'SHIP' | 'COMPANY'; @@ -37,11 +39,11 @@ function getBadgeColor(categoryCode: string): { bg: string; text: string } { return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' }; } -export default function ComplianceTab({ lang }: ComplianceTabProps) { +export default function ComplianceTab({ lang, indicatorType: fixedType }: ComplianceTabProps) { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [indicatorType, setIndicatorType] = useState('SHIP'); + const [indicatorType, setIndicatorType] = useState(fixedType ?? 'SHIP'); const [expandedCategories, setExpandedCategories] = useState>(new Set()); const cache = useRef>(new Map()); @@ -93,35 +95,37 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) { return (
- {/* Ship / Company 토글 */} -
- {(['SHIP', 'COMPANY'] as const).map((type) => ( - - ))} -
+ {/* Ship / Company 토글 (fixedType이 없을 때만 표시) */} + {!fixedType && ( +
+ {(['SHIP', 'COMPANY'] as const).map((type) => ( + + ))} +
+ )} {loading && (
-
데이터를 불러오는 중...
+
{t(lang, 'loading')}
)} {error && (
- 데이터 로딩 실패: {error} + {t(lang, 'loadError')} {error}
)} diff --git a/frontend/src/components/screening/HistoryTab.tsx b/frontend/src/components/screening/HistoryTab.tsx index dcbfbd8..1e2a3df 100644 --- a/frontend/src/components/screening/HistoryTab.tsx +++ b/frontend/src/components/screening/HistoryTab.tsx @@ -1,5 +1,7 @@ import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; import { screeningGuideApi, type ChangeHistoryResponse, type ShipInfoResponse, type CompanyInfoResponse, type IndicatorStatusResponse } from '../../api/screeningGuideApi'; +import { t } from '../../constants/screeningTexts'; type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance'; @@ -7,32 +9,34 @@ interface HistoryTabProps { lang: string; } -const HISTORY_TYPES: { +interface HistoryTypeConfig { key: HistoryType; - label: string; - searchLabel: string; - searchPlaceholder: string; + labelKey: string; + searchLabelKey: string; + searchPlaceholderKey: string; overallColumn: string | null; -}[] = [ +} + +const HISTORY_TYPES: HistoryTypeConfig[] = [ { key: 'ship-risk', - label: '선박 위험지표', - searchLabel: 'IMO 번호', - searchPlaceholder: 'IMO 번호 : 9672533', + labelKey: 'histTypeShipRisk', + searchLabelKey: 'searchLabelImo', + searchPlaceholderKey: 'searchPlaceholderImo', overallColumn: null, }, { key: 'ship-compliance', - label: '선박 제재', - searchLabel: 'IMO 번호', - searchPlaceholder: 'IMO 번호 : 9672533', + labelKey: 'histTypeShipCompliance', + searchLabelKey: 'searchLabelImo', + searchPlaceholderKey: 'searchPlaceholderImo', overallColumn: 'lgl_snths_sanction', }, { key: 'company-compliance', - label: '회사 제재', - searchLabel: '회사 코드', - searchPlaceholder: '회사 코드 : 1288896', + labelKey: 'histTypeCompanyCompliance', + searchLabelKey: 'searchLabelCompany', + searchPlaceholderKey: 'searchPlaceholderCompany', overallColumn: 'company_snths_compliance_status', }, ]; @@ -62,8 +66,19 @@ const STATUS_LABELS: Record = { '2': 'Severe', }; -function StatusBadge({ value }: { value: string | null }) { +function isNoDataValue(v: string | null | undefined): boolean { + return v === '-999' || v === '-999.0' || v === 'null'; +} + +function StatusBadge({ value, lang = 'EN' }: { value: string | null; lang?: string }) { if (value == null || value === '') return null; + if (isNoDataValue(value)) { + return ( + + {t(lang, 'noData')} + + ); + } const status = STATUS_MAP[value]; if (!status) return {value}; return ( @@ -80,8 +95,16 @@ function countryFlag(code: string | null | undefined): string { return String.fromCodePoint(...codePoints); } -function RiskValueCell({ value, narrative }: { value: string | null; narrative?: string }) { +function RiskValueCell({ value, narrative, lang = 'EN' }: { value: string | null; narrative?: string; lang?: string }) { if (value == null || value === '') return null; + if (isNoDataValue(value)) { + return ( +
+ + {t(lang, 'noData')} +
+ ); + } const color = STATUS_COLORS[value] ?? '#6b7280'; const label = narrative || STATUS_LABELS[value] || value; return ( @@ -98,7 +121,8 @@ const COMPLIANCE_STATUS: Record = '2': { label: 'Yes', className: 'bg-red-100 text-red-800' }, }; -function getRiskLabel(item: IndicatorStatusResponse): string { +function getRiskLabel(item: IndicatorStatusResponse, lang: string): string { + if (isNoDataValue(item.value)) return t(lang, 'noData'); // 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 @@ -109,7 +133,7 @@ function getRiskLabel(item: IndicatorStatusResponse): string { return item.narrative || STATUS_LABELS[item.value ?? ''] || item.value || '-'; } -function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) { +function RiskStatusGrid({ items, lang }: { items: IndicatorStatusResponse[]; lang: string }) { // Risk Data Maintained 체크: Not Maintained(1)이면 해당 지표만 표시 const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint'); const isNotMaintained = riskDataMaintained?.value === '1'; @@ -132,7 +156,7 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
{isNotMaintained && (
- Risk Data is not maintained for this vessel. Only the maintenance status is shown. + {t(lang, 'riskNotMaintained')}
)}
@@ -144,7 +168,7 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
{catItems.map((item) => { const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280'; - const label = getRiskLabel(item); + const label = getRiskLabel(item, lang); return (
{item.fieldName} @@ -172,7 +196,6 @@ const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: { key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' }, { key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' }, { key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' }, - { key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' }, ]; // Compliance 예외 처리 @@ -189,13 +212,16 @@ function getComplianceLabel(item: IndicatorStatusResponse, isCompany: boolean): 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); +function ComplianceStatusItem({ item, isCompany, lang = 'EN' }: { item: IndicatorStatusResponse; isCompany: boolean; lang?: string }) { + const isNoData = isNoDataValue(item.value); + const overrideLabel = isNoData ? t(lang, 'noData') : 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'; + const displayClass = isNoData + ? 'bg-gray-100 text-gray-500 border border-gray-300' + : 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 (
@@ -210,7 +236,7 @@ function ComplianceStatusItem({ item, isCompany }: { item: IndicatorStatusRespon ); } -function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResponse[]; isCompany: boolean }) { +function ComplianceStatusGrid({ items, isCompany, lang }: { items: IndicatorStatusResponse[]; isCompany: boolean; lang: string }) { const [activeTab, setActiveTab] = useState('sanctions'); // 제외 항목 필터링 @@ -240,7 +266,7 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp )}
{catItems.map((item) => ( - + ))}
@@ -302,14 +328,14 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp )}
{catItems.map((item) => ( - + ))}
))}
) : ( -
해당 항목이 없습니다.
+
{t(lang, 'noItems')}
)}
); @@ -329,7 +355,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) { const [complianceStatusCache, setComplianceStatusCache] = useState>({}); const [expandedSections, setExpandedSections] = useState>(new Set(['info', 'risk', 'compliance', 'history'])); - const currentType = HISTORY_TYPES.find((t) => t.key === historyType)!; + const currentType = HISTORY_TYPES.find((ht) => ht.key === historyType)!; const isRisk = historyType === 'ship-risk'; const data = cache[lang] ?? []; const riskStatus = riskStatusCache[lang] ?? []; @@ -465,17 +491,17 @@ export default function HistoryTab({ lang }: HistoryTabProps) { {/* 이력 유형 선택 (언더라인 탭) */}
- {HISTORY_TYPES.map((t) => ( + {HISTORY_TYPES.map((ht) => ( ))}
@@ -499,7 +525,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) { value={searchValue} onChange={(e) => setSearchValue(e.target.value)} onKeyDown={handleKeyDown} - placeholder={currentType.searchPlaceholder} + placeholder={t(lang, currentType.searchPlaceholderKey)} 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" /> @@ -519,14 +545,14 @@ export default function HistoryTab({ lang }: HistoryTabProps) { 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 ? '조회 중...' : '조회'} + {loading ? t(lang, 'searching') : t(lang, 'search')}
{/* 에러 */} {error && (
- 조회 실패: {error} + {t(lang, 'loadError')} {error}
)} @@ -542,7 +568,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) { > - {shipInfo ? '선박 기본 정보' : '회사 기본 정보'} + {shipInfo ? t(lang, 'shipBasicInfo') : t(lang, 'companyBasicInfo')} {expandedSections.has('info') && ( @@ -576,13 +602,13 @@ export default function HistoryTab({ lang }: HistoryTabProps) { {/* 우측: 스펙 정보 */}
- 국적 + {t(lang, 'nationality')} {countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'}
- 선종 + {t(lang, 'shipType')} {shipInfo.shipType || '-'}
@@ -594,7 +620,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) { {shipInfo.gt || '-'}
- 건조연도 + {t(lang, 'buildYear')} {shipInfo.buildYear || '-'}
@@ -621,7 +647,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
{companyInfo.parentCompanyName && (
- 모회사 + {t(lang, 'parentCompany')} {companyInfo.parentCompanyName}
)} @@ -634,14 +660,14 @@ export default function HistoryTab({ lang }: HistoryTabProps) { {/* 우측: 상세 정보 */}
- 등록국가 + {t(lang, 'regCountry')} {countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'}
{companyInfo.controlCountry && (
- 관리국가 + {t(lang, 'ctrlCountry')} {countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry} @@ -649,25 +675,25 @@ export default function HistoryTab({ lang }: HistoryTabProps) { )} {companyInfo.foundedDate && (
- 설립일 + {t(lang, 'foundedDate')} {companyInfo.foundedDate}
)} {companyInfo.email && (
- 이메일 + {t(lang, 'email')} {companyInfo.email}
)} {companyInfo.phone && (
- 전화 + {t(lang, 'phone')} {companyInfo.phone}
)} {companyInfo.website && (
- 웹사이트 + {t(lang, 'website')} 0 && (
- +
+ + + {t(lang, 'viewGuide')} → + +
{expandedSections.has('risk') && (
- +
)}
@@ -712,19 +746,27 @@ export default function HistoryTab({ lang }: HistoryTabProps) { : null; return (
- +
+ + + {t(lang, 'viewGuide')} → + +
{expandedSections.has('compliance') && (
- +
)}
@@ -738,9 +780,9 @@ export default function HistoryTab({ lang }: HistoryTabProps) { className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors" > - 값 변경 이력 + {t(lang, 'valueChangeHistory')} - {grouped.length}개 일시, {data.length}건 변동 + {grouped.length}{t(lang, 'dateCount')}, {data.length}{t(lang, 'changeCount')} {expandedSections.has('history') && ( @@ -779,19 +821,19 @@ export default function HistoryTab({ lang }: HistoryTabProps) { {hasOverall ? (
- + - +
) : ( - {displayItems.length}건 변동 + {displayItems.length}{t(lang, 'changeCount')} )}
{hasOverall && ( - {displayItems.length}건 변동 + {displayItems.length}{t(lang, 'changeCount')} )} @@ -804,19 +846,19 @@ export default function HistoryTab({ lang }: HistoryTabProps) { style={{ width: '50%' }} className="px-4 py-2 text-center font-semibold" > - 필드명 + {t(lang, 'colFieldName')} - 이전값 + {t(lang, 'colBefore')} - 이후값 + {t(lang, 'colAfter')} @@ -845,7 +887,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) { style={{ width: '25%' }} className="px-4 py-2.5 text-center" > - + @@ -863,13 +906,13 @@ export default function HistoryTab({ lang }: HistoryTabProps) { style={{ width: '25%' }} className="px-4 py-2.5 text-center" > - + - + )} @@ -887,7 +930,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
📭
-
변경 이력이 없습니다.
+
{t(lang, 'noChangeHistory')}
)} @@ -902,7 +945,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
🔍
-
{currentType.searchLabel}을(를) 입력하고 조회하세요.
+
{t(lang, 'enterSearchKey').replace('{label}', t(lang, currentType.searchLabelKey))}
)} diff --git a/frontend/src/components/screening/MethodologyTab.tsx b/frontend/src/components/screening/MethodologyTab.tsx index cca8945..c99db88 100644 --- a/frontend/src/components/screening/MethodologyTab.tsx +++ b/frontend/src/components/screening/MethodologyTab.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi'; +import { t } from '../../constants/screeningTexts'; interface MethodologyTabProps { lang: string; @@ -30,7 +31,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { const [banner, setBanner] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedType, setSelectedType] = useState('전체'); + const [selectedType, setSelectedType] = useState('ALL'); const cache = useRef>(new Map()); const fetchData = useCallback((fetchLang: string) => { @@ -81,7 +82,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { const uniqueTypes = Array.from(new Set(history.map((h) => h.changeType))); const filtered = - selectedType === '전체' + selectedType === 'ALL' ? sortedHistory : sortedHistory.filter((h) => h.changeType === selectedType); @@ -90,7 +91,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
-
데이터를 불러오는 중...
+
{t(lang, 'loading')}
); @@ -99,7 +100,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { if (error) { return (
- 데이터 로딩 실패: {error} + {t(lang, 'loadError')} {error}
); } @@ -116,14 +117,14 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { {/* 변경 유형 필터 */}
{uniqueTypes.map((type) => { const count = history.filter((h) => h.changeType === type).length; @@ -132,7 +133,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { return (
- 표시: {filtered.length}건 | 최신순 정렬 + {t(lang, 'showing')} {filtered.length}{t(lang, 'unit')} {t(lang, 'sortLatest')}
{/* 타임라인 목록 */} @@ -156,7 +157,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) { - {['날짜', '변경 유형', '설명'].map((h) => ( + {[t(lang, 'colDate'), t(lang, 'colChangeType'), t(lang, 'colDescription')].map((h) => (
- 해당 유형의 변경 이력이 없습니다. + {t(lang, 'noMethodologyHistory')} )} diff --git a/frontend/src/components/screening/RiskTab.tsx b/frontend/src/components/screening/RiskTab.tsx index d8b6c13..f3b336b 100644 --- a/frontend/src/components/screening/RiskTab.tsx +++ b/frontend/src/components/screening/RiskTab.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi'; +import { t } from '../../constants/screeningTexts'; interface RiskTabProps { lang: string; @@ -75,7 +76,7 @@ export default function RiskTab({ lang }: RiskTabProps) {
-
데이터를 불러오는 중...
+
{t(lang, 'loading')}
); @@ -84,7 +85,7 @@ export default function RiskTab({ lang }: RiskTabProps) { if (error) { return (
- 데이터 로딩 실패: {error} + {t(lang, 'loadError')} {error}
); } diff --git a/frontend/src/constants/screeningTexts.ts b/frontend/src/constants/screeningTexts.ts new file mode 100644 index 0000000..fcd4e7c --- /dev/null +++ b/frontend/src/constants/screeningTexts.ts @@ -0,0 +1,170 @@ +/** + * Risk & Compliance 섹션 UI 고정 텍스트 다국어 메타 + * + * lang prop('EN' | 'KO')에 따라 텍스트를 반환한다. + * 사용: t(lang, 'key') 또는 screeningTexts[lang].key + */ + +interface TextMap { + [key: string]: string; +} + +const EN: TextMap = { + // --- 공통 --- + loading: 'Loading...', + loadError: 'Failed to load data:', + noData: 'No Data', + + // --- RiskComplianceHistory (페이지) --- + changeHistoryTitle: 'Risk & Compliance Change History', + changeHistorySubtitle: 'S&P Risk Indicator and Compliance Value Change History', + + // --- ScreeningGuide (페이지) --- + screeningGuideTitle: 'Risk & Compliance Screening Guide', + screeningGuideSubtitle: 'S&P Risk Indicators and Regulatory Compliance Screening Guide', + tabShipCompliance: 'Ship Compliance', + tabCompanyCompliance: 'Company Compliance', + tabRiskIndicators: 'Ship Risk Indicator', + tabMethodology: 'Methodology History', + + // --- HistoryTab --- + histTypeShipRisk: 'Ship Risk Indicator', + histTypeShipCompliance: 'Ship Compliance', + histTypeCompanyCompliance: 'Company Compliance', + searchLabelImo: 'IMO Number', + searchLabelCompany: 'Company Code', + searchPlaceholderImo: 'IMO Number : 9672533', + searchPlaceholderCompany: 'Company Code : 1288896', + search: 'Search', + searching: 'Searching...', + shipBasicInfo: 'Ship Basic Info', + companyBasicInfo: 'Company Basic Info', + valueChangeHistory: 'Value Change History', + colFieldName: 'Field Name', + colBefore: 'Before', + colAfter: 'After', + noChangeHistory: 'No change history.', + noItems: 'No items found.', + dateCount: 'dates', + changeCount: 'changes', + enterSearchKey: 'Enter {label} to search.', + + // 선박/회사 기본 정보 라벨 + nationality: 'Nationality', + shipType: 'Ship Type', + buildYear: 'Build Year', + regCountry: 'Reg. Country', + ctrlCountry: 'Ctrl Country', + foundedDate: 'Founded', + email: 'Email', + phone: 'Phone', + website: 'Website', + parentCompany: 'Parent', + + // --- MethodologyTab --- + all: 'All', + showing: 'Showing:', + unit: '', + sortLatest: '| Latest first', + colDate: 'Date', + colChangeType: 'Change Type', + colDescription: 'Description', + noMethodologyHistory: 'No history for this type.', + + // --- HistoryTab 현재 상태 --- + currentRiskIndicators: 'Current Risk Indicators', + currentCompliance: 'Current Compliance', + riskNotMaintained: 'Risk Data is not maintained for this vessel. Only the maintenance status is shown.', + + // --- Screening Guide 링크 --- + viewGuide: 'View Guide', +}; + +const KO: TextMap = { + // --- 공통 --- + loading: '데이터를 불러오는 중...', + loadError: '데이터 로딩 실패:', + noData: '데이터 없음', + + // --- RiskComplianceHistory (페이지) --- + changeHistoryTitle: 'Risk & Compliance Change History', + changeHistorySubtitle: 'S&P 위험 지표 및 규정 준수 값 변경 이력', + + // --- ScreeningGuide (페이지) --- + screeningGuideTitle: 'Risk & Compliance Screening Guide', + screeningGuideSubtitle: 'S&P 위험 지표 및 규정 준수 Screening Guide', + tabShipCompliance: '선박 규정 준수', + tabCompanyCompliance: '회사 규정 준수', + tabRiskIndicators: '선박 위험 지표', + tabMethodology: '방법론 변경 이력', + + // --- HistoryTab --- + histTypeShipRisk: '선박 위험 지표', + histTypeShipCompliance: '선박 규정 준수', + histTypeCompanyCompliance: '회사 규정 준수', + searchLabelImo: 'IMO 번호', + searchLabelCompany: '회사 코드', + searchPlaceholderImo: 'IMO 번호 : 9290933', + searchPlaceholderCompany: '회사 코드 : 1288896', + search: '조회', + searching: '조회 중...', + shipBasicInfo: '선박 기본 정보', + companyBasicInfo: '회사 기본 정보', + valueChangeHistory: '값 변경 이력', + colFieldName: '필드명', + colBefore: '이전값', + colAfter: '이후값', + noChangeHistory: '변경 이력이 없습니다.', + noItems: '해당 항목이 없습니다.', + dateCount: '개 일시', + changeCount: '건 변동', + enterSearchKey: '{label}을(를) 입력하고 조회하세요.', + + // 선박/회사 기본 정보 라벨 + nationality: '국적', + shipType: '선종', + buildYear: '건조연도', + regCountry: '등록국가', + ctrlCountry: '관리국가', + foundedDate: '설립일', + email: '이메일', + phone: '전화', + website: '웹사이트', + parentCompany: '모회사', + + // --- MethodologyTab --- + all: '전체', + showing: '표시:', + unit: '건', + sortLatest: '| 최신순 정렬', + colDate: '날짜', + colChangeType: '변경 유형', + colDescription: '설명', + noMethodologyHistory: '해당 유형의 변경 이력이 없습니다.', + + // --- HistoryTab 현재 상태 --- + currentRiskIndicators: '현재 위험 지표 상태', + currentCompliance: '현재 규정 준수 상태', + riskNotMaintained: '이 선박의 위험 지표 데이터가 관리되지 않습니다. 관리 대상만 위첨 지표가 표시됩니다.', + + // --- Screening Guide 링크 --- + viewGuide: '가이드 보기', +}; + +const TEXTS: Record = { EN, KO }; + +/** + * lang에 맞는 텍스트를 반환. 키가 없으면 키 자체를 반환. + */ +export function t(lang: string, key: string): string { + return TEXTS[lang]?.[key] ?? TEXTS['EN']?.[key] ?? key; +} + +/** + * -999 값을 lang에 맞는 "No Data" 텍스트로 변환. + * -999가 아니면 원래 값을 그대로 반환. + */ +export function resolveNoData(lang: string, value: string | null | undefined): string | null | undefined { + if (value === '-999' || value === '-999.0') return t(lang, 'noData'); + return value; +} diff --git a/frontend/src/pages/RiskComplianceHistory.tsx b/frontend/src/pages/RiskComplianceHistory.tsx index 9f72f15..08d32ee 100644 --- a/frontend/src/pages/RiskComplianceHistory.tsx +++ b/frontend/src/pages/RiskComplianceHistory.tsx @@ -1,17 +1,24 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import HistoryTab from '../components/screening/HistoryTab'; +import { t } from '../constants/screeningTexts'; + +const LANG_KEY = 'screening-lang'; export default function RiskComplianceHistory() { - const [lang, setLang] = useState('KO'); + const [lang, setLangState] = useState(() => localStorage.getItem(LANG_KEY) || 'KO'); + const setLang = useCallback((l: string) => { + setLangState(l); + localStorage.setItem(LANG_KEY, l); + }, []); return (
{/* 헤더 + 언어 토글 */}
-

Risk & Compliance Change History

+

{t(lang, 'changeHistoryTitle')}

- S&P 위험 지표 및 규정 준수 값 변경 이력 + {t(lang, 'changeHistorySubtitle')}

diff --git a/frontend/src/pages/ScreeningGuide.tsx b/frontend/src/pages/ScreeningGuide.tsx index 66b9a46..cc7a802 100644 --- a/frontend/src/pages/ScreeningGuide.tsx +++ b/frontend/src/pages/ScreeningGuide.tsx @@ -1,19 +1,32 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; import RiskTab from '../components/screening/RiskTab'; import ComplianceTab from '../components/screening/ComplianceTab'; import MethodologyTab from '../components/screening/MethodologyTab'; +import { t } from '../constants/screeningTexts'; -type ActiveTab = 'compliance' | 'risk' | 'methodology'; - -const TABS: { key: ActiveTab; label: string }[] = [ - { key: 'compliance', label: 'Compliance' }, - { key: 'risk', label: 'Risk Indicators' }, - { key: 'methodology', label: 'Methodology History' }, -]; +type ActiveTab = 'risk' | 'ship-compliance' | 'company-compliance' | 'methodology'; +const VALID_TABS: ActiveTab[] = ['risk', 'ship-compliance', 'company-compliance', 'methodology']; +const LANG_KEY = 'screening-lang'; export default function ScreeningGuide() { - const [activeTab, setActiveTab] = useState('compliance'); - const [lang, setLang] = useState('EN'); + const [searchParams] = useSearchParams(); + const initialTab = VALID_TABS.includes(searchParams.get('tab') as ActiveTab) + ? (searchParams.get('tab') as ActiveTab) + : 'risk'; + const [activeTab, setActiveTab] = useState(initialTab); + const [lang, setLangState] = useState(() => localStorage.getItem(LANG_KEY) || 'EN'); + const setLang = useCallback((l: string) => { + setLangState(l); + localStorage.setItem(LANG_KEY, l); + }, []); + + const tabs: { key: ActiveTab; label: string }[] = [ + { key: 'risk', label: t(lang, 'tabRiskIndicators') }, + { key: 'ship-compliance', label: t(lang, 'tabShipCompliance') }, + { key: 'company-compliance', label: t(lang, 'tabCompanyCompliance') }, + { key: 'methodology', label: t(lang, 'tabMethodology') }, + ]; return (
@@ -21,10 +34,10 @@ export default function ScreeningGuide() {

- Risk & Compliance Screening Guide + {t(lang, 'screeningGuideTitle')}

- S&P Risk Indicators and Regulatory Compliance Screening Guide + {t(lang, 'screeningGuideSubtitle')}

{/* 언어 토글 */} @@ -48,7 +61,7 @@ export default function ScreeningGuide() { {/* 언더라인 탭 */}
- {TABS.map((tab) => ( + {tabs.map((tab) => (
{/* 탭 내용 */} - {activeTab === 'compliance' && } {activeTab === 'risk' && } + {activeTab === 'ship-compliance' && } + {activeTab === 'company-compliance' && } {activeTab === 'methodology' && }
); diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java index 40a5945..adc754f 100644 --- a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.servers.Server; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,10 +21,9 @@ import java.util.List; * - API 문서 (JSON): http://localhost:8041/snp-api/v3/api-docs * - API 문서 (YAML): http://localhost:8041/snp-api/v3/api-docs.yaml * - * 주요 기능: - * - REST API 자동 문서화 - * - API 테스트 UI 제공 - * - OpenAPI 3.0 스펙 준수 + * 환경별 노출: + * - dev: 모든 API 그룹 노출 + * - prod: Bypass API 그룹만 노출 */ @Configuration public class SwaggerConfig { @@ -34,27 +34,45 @@ public class SwaggerConfig { @Value("${server.servlet.context-path:}") private String contextPath; + @Value("${app.environment:dev}") + private String environment; + @Bean + @ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true) public GroupedOpenApi batchManagementApi() { return GroupedOpenApi.builder() .group("1. Batch Management") .pathsToMatch("/api/batch/**") + .addOpenApiCustomizer(openApi -> openApi.info(new Info() + .title("Batch Management API") + .description("배치 Job 실행, 이력 조회, 스케줄 관리 API") + .version("v1.0.0"))) .build(); } @Bean + @ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true) public GroupedOpenApi bypassConfigApi() { return GroupedOpenApi.builder() .group("2. Bypass Config") .pathsToMatch("/api/bypass-config/**") + .addOpenApiCustomizer(openApi -> openApi.info(new Info() + .title("Bypass Config API") + .description("Bypass API 설정 및 코드 생성 관리 API") + .version("v1.0.0"))) .build(); } @Bean + @ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true) public GroupedOpenApi screeningGuideApi() { return GroupedOpenApi.builder() .group("4. Screening Guide") .pathsToMatch("/api/screening-guide/**") + .addOpenApiCustomizer(openApi -> openApi.info(new Info() + .title("Screening Guide API") + .description("Risk & Compliance Screening Guide 조회 API") + .version("v1.0.0"))) .build(); } @@ -64,14 +82,21 @@ public class SwaggerConfig { .group("3. Bypass API") .pathsToMatch("/api/**") .pathsToExclude("/api/batch/**", "/api/bypass-config/**", "/api/screening-guide/**") + .addOpenApiCustomizer(openApi -> openApi.info(new Info() + .title("Bypass API") + .description("외부 연동용 S&P 데이터 Bypass API") + .version("v1.0.0"))) .build(); } @Bean public OpenAPI openAPI() { - return new OpenAPI() - .info(apiInfo()) - .servers(List.of( + List servers = "prod".equals(environment) + ? List.of( + new Server() + .url("https://guide.gc-si.dev" + contextPath) + .description("GC 도메인")) + : List.of( new Server() .url("http://localhost:" + serverPort + contextPath) .description("로컬 개발 서버"), @@ -79,12 +104,15 @@ public class SwaggerConfig { .url("http://211.208.115.83:" + serverPort + contextPath) .description("중계 서버"), new Server() - .url("https://guide.gc-si.dev" + contextPath) - .description("GC 도메인") - )); + .url("https://guide.gc-si.dev" + contextPath) + .description("GC 도메인")); + + return new OpenAPI() + .info(defaultApiInfo()) + .servers(servers); } - private Info apiInfo() { + private Info defaultApiInfo() { return new Info() .title("SNP Batch REST API") .description(""" diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2b55b9e..bc8870f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -77,6 +77,7 @@ logging: # Custom Application Properties app: + environment: prod batch: chunk-size: 1000 schedule: