release: 2026-04-02 (6건 커밋) #139

병합
HYOJIN develop 에서 main 로 8 commits 를 머지했습니다 2026-04-02 13:44:56 +09:00
10개의 변경된 파일428개의 추가작업 그리고 145개의 파일을 삭제

파일 보기

@ -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]
### 추가

파일 보기

@ -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<ComplianceCategoryResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [indicatorType, setIndicatorType] = useState<IndicatorType>('SHIP');
const [indicatorType, setIndicatorType] = useState<IndicatorType>(fixedType ?? 'SHIP');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const cache = useRef<Map<CacheKey, ComplianceCategoryResponse[]>>(new Map());
@ -93,35 +95,37 @@ export default function ComplianceTab({ lang }: ComplianceTabProps) {
return (
<div className="space-y-4">
{/* Ship / Company 토글 */}
<div className="flex gap-2">
{(['SHIP', 'COMPANY'] as const).map((type) => (
<button
key={type}
onClick={() => setIndicatorType(type)}
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
indicatorType === type
? 'bg-wing-text text-wing-bg shadow-sm'
: 'bg-wing-card text-wing-muted border border-wing-border hover:text-wing-text'
}`}
>
{type === 'SHIP' ? 'Ship' : 'Company'}
</button>
))}
</div>
{/* Ship / Company 토글 (fixedType이 없을 때만 표시) */}
{!fixedType && (
<div className="flex gap-2">
{(['SHIP', 'COMPANY'] as const).map((type) => (
<button
key={type}
onClick={() => setIndicatorType(type)}
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
indicatorType === type
? 'bg-wing-text text-wing-bg shadow-sm'
: 'bg-wing-card text-wing-muted border border-wing-border hover:text-wing-text'
}`}
>
{type === 'SHIP' ? 'Ship' : '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 className="text-sm">{t(lang, 'loading')}</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}
<strong>{t(lang, 'loadError')}</strong> {error}
</div>
)}

파일 보기

@ -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<string, string> = {
'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 (
<span className="inline-block rounded-full px-3 py-0.5 text-xs font-bold border bg-gray-100 text-gray-500 border-gray-300">
{t(lang, 'noData')}
</span>
);
}
const status = STATUS_MAP[value];
if (!status) return <span className="text-xs text-wing-muted">{value}</span>;
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 (
<div className="inline-flex items-start gap-1.5">
<span style={{ color: '#9ca3af' }} className="text-sm leading-tight"></span>
<span className="text-xs text-gray-500 leading-relaxed text-left">{t(lang, 'noData')}</span>
</div>
);
}
const color = STATUS_COLORS[value] ?? '#6b7280';
const label = narrative || STATUS_LABELS[value] || value;
return (
@ -98,7 +121,8 @@ const COMPLIANCE_STATUS: Record<string, { label: string; className: string }> =
'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[] }) {
<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.
{t(lang, 'riskNotMaintained')}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
@ -144,7 +168,7 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
<div className="space-y-1.5">
{catItems.map((item) => {
const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280';
const label = getRiskLabel(item);
const label = getRiskLabel(item, lang);
return (
<div key={item.columnName} className="flex items-center gap-2 text-xs">
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
@ -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 (
<div className="flex items-center gap-2 text-xs">
@ -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
)}
<div className="space-y-1.5">
{catItems.map((item) => (
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} lang={lang} />
))}
</div>
</div>
@ -302,14 +328,14 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp
)}
<div className="space-y-1.5">
{catItems.map((item) => (
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} />
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} lang={lang} />
))}
</div>
</div>
))}
</div>
) : (
<div className="text-center text-xs text-wing-muted py-4"> .</div>
<div className="text-center text-xs text-wing-muted py-4">{t(lang, 'noItems')}</div>
)}
</div>
);
@ -329,7 +355,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
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 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) {
{/* 이력 유형 선택 (언더라인 탭) */}
<div className="border-b border-wing-border">
<div className="flex gap-6">
{HISTORY_TYPES.map((t) => (
{HISTORY_TYPES.map((ht) => (
<button
key={t.key}
onClick={() => handleTypeChange(t.key)}
key={ht.key}
onClick={() => handleTypeChange(ht.key)}
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
historyType === t.key
historyType === ht.key
? 'text-blue-600 border-blue-600'
: 'text-wing-muted border-transparent hover:text-wing-text'
}`}
>
{t.label}
{t(lang, ht.labelKey)}
</button>
))}
</div>
@ -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')}
</button>
</div>
{/* 에러 */}
{error && (
<div className="bg-red-50 border border-red-300 rounded-xl p-4 text-red-800 text-sm">
<strong> :</strong> {error}
<strong>{t(lang, 'loadError')}</strong> {error}
</div>
)}
@ -542,7 +568,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
>
<span className={`text-xs transition-transform ${expandedSections.has('info') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">
{shipInfo ? '선박 기본 정보' : '회사 기본 정보'}
{shipInfo ? t(lang, 'shipBasicInfo') : t(lang, 'companyBasicInfo')}
</span>
</button>
{expandedSections.has('info') && (
@ -576,13 +602,13 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
{/* 우측: 스펙 정보 */}
<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="text-wing-muted w-16">{t(lang, 'nationality')}</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="text-wing-muted w-16">{t(lang, 'shipType')}</span>
<span className="font-medium text-wing-text">{shipInfo.shipType || '-'}</span>
</div>
<div className="flex items-center gap-2">
@ -594,7 +620,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
<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="text-wing-muted w-16">{t(lang, 'buildYear')}</span>
<span className="font-medium text-wing-text">{shipInfo.buildYear || '-'}</span>
</div>
</div>
@ -621,7 +647,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
</div>
{companyInfo.parentCompanyName && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="text-wing-muted w-16">{t(lang, 'parentCompany')}</span>
<span className="font-medium text-wing-text">{companyInfo.parentCompanyName}</span>
</div>
)}
@ -634,14 +660,14 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
{/* 우측: 상세 정보 */}
<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="text-wing-muted w-16">{t(lang, 'regCountry')}</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="text-wing-muted w-16">{t(lang, 'ctrlCountry')}</span>
<span className="font-medium text-wing-text">
{countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry}
</span>
@ -649,25 +675,25 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
)}
{companyInfo.foundedDate && (
<div className="flex items-center gap-2">
<span className="text-wing-muted w-16"></span>
<span className="text-wing-muted w-16">{t(lang, 'foundedDate')}</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="text-wing-muted w-16">{t(lang, 'email')}</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="text-wing-muted w-16">{t(lang, 'phone')}</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>
<span className="text-wing-muted w-16">{t(lang, 'website')}</span>
<a
href={companyInfo.website.startsWith('http') ? companyInfo.website : `https://${companyInfo.website}`}
target="_blank"
@ -689,16 +715,24 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
{/* 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' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">Current Risk Indicators</span>
</button>
<div className="flex items-center">
<button
onClick={() => toggleSection('risk')}
className="flex-1 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' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">{t(lang, 'currentRiskIndicators')}</span>
</button>
<Link
to="/screening-guide?tab=risk"
className="shrink-0 mr-4 text-[11px] text-wing-muted hover:text-blue-600 transition-colors"
>
{t(lang, 'viewGuide')}
</Link>
</div>
{expandedSections.has('risk') && (
<div className="border-t border-wing-border px-4 py-4">
<RiskStatusGrid items={riskStatus} />
<RiskStatusGrid items={riskStatus} lang={lang} />
</div>
)}
</div>
@ -712,19 +746,27 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
: 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' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">Current Compliance</span>
{overallItem && (
<StatusBadge value={overallItem.value} />
)}
</button>
<div className="flex items-center">
<button
onClick={() => toggleSection('compliance')}
className="flex-1 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' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text">{t(lang, 'currentCompliance')}</span>
{overallItem && (
<StatusBadge value={overallItem.value} lang={lang} />
)}
</button>
<Link
to={`/screening-guide?tab=${isCompany ? 'company-compliance' : 'ship-compliance'}`}
className="shrink-0 mr-4 text-[11px] text-wing-muted hover:text-blue-600 transition-colors"
>
{t(lang, 'viewGuide')}
</Link>
</div>
{expandedSections.has('compliance') && (
<div className="border-t border-wing-border px-4 py-4">
<ComplianceStatusGrid items={complianceStatus} isCompany={isCompany} />
<ComplianceStatusGrid items={complianceStatus} isCompany={isCompany} lang={lang} />
</div>
)}
</div>
@ -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"
>
<span className={`text-xs transition-transform ${expandedSections.has('history') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text"> </span>
<span className="text-sm font-semibold text-wing-text">{t(lang, 'valueChangeHistory')}</span>
<span className="text-xs text-wing-muted">
{grouped.length} , {data.length}
{grouped.length}{t(lang, 'dateCount')}, {data.length}{t(lang, 'changeCount')}
</span>
</button>
{expandedSections.has('history') && (
@ -779,19 +821,19 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
</span>
{hasOverall ? (
<div className="flex items-center gap-1.5">
<StatusBadge value={group.overallBefore} />
<StatusBadge value={group.overallBefore} lang={lang} />
<span className="text-xs text-wing-muted"></span>
<StatusBadge value={group.overallAfter} />
<StatusBadge value={group.overallAfter} lang={lang} />
</div>
) : (
<span className="text-xs text-wing-muted">
{displayItems.length}
{displayItems.length}{t(lang, 'changeCount')}
</span>
)}
</div>
{hasOverall && (
<span className="text-xs text-wing-muted">
{displayItems.length}
{displayItems.length}{t(lang, 'changeCount')}
</span>
)}
</button>
@ -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')}
</th>
<th
style={{ width: '25%' }}
className="px-4 py-2 text-center font-semibold"
>
{t(lang, 'colBefore')}
</th>
<th
style={{ width: '25%' }}
className="px-4 py-2 text-center font-semibold"
>
{t(lang, 'colAfter')}
</th>
</tr>
</thead>
@ -845,7 +887,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} />
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} lang={lang} />
</td>
<td
style={{ width: '25%' }}
@ -854,6 +896,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
<RiskValueCell
value={row.afterValue}
narrative={row.narrative ?? undefined}
lang={lang}
/>
</td>
</>
@ -863,13 +906,13 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<StatusBadge value={row.beforeValue} />
<StatusBadge value={row.beforeValue} lang={lang} />
</td>
<td
style={{ width: '25%' }}
className="px-4 py-2.5 text-center"
>
<StatusBadge value={row.afterValue} />
<StatusBadge value={row.afterValue} lang={lang} />
</td>
</>
)}
@ -887,7 +930,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
<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 className="text-sm">{t(lang, 'noChangeHistory')}</div>
</div>
</div>
)}
@ -902,7 +945,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
<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 className="text-sm">{t(lang, 'enterSearchKey').replace('{label}', t(lang, currentType.searchLabelKey))}</div>
</div>
</div>
)}

파일 보기

@ -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<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState('전체');
const [selectedType, setSelectedType] = useState('ALL');
const cache = useRef<Map<LangKey, LangCache>>(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) {
<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 className="text-sm">{t(lang, 'loading')}</div>
</div>
</div>
);
@ -99,7 +100,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
if (error) {
return (
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
<strong> :</strong> {error}
<strong>{t(lang, 'loadError')}</strong> {error}
</div>
);
}
@ -116,14 +117,14 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
{/* 변경 유형 필터 */}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSelectedType('전체')}
onClick={() => setSelectedType('ALL')}
className={`px-3 py-1 rounded-full text-[11px] font-bold transition-colors border ${
selectedType === '전체'
selectedType === 'ALL'
? 'bg-slate-900 text-white border-slate-900'
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
}`}
>
({history.length})
{t(lang, 'all')} ({history.length})
</button>
{uniqueTypes.map((type) => {
const count = history.filter((h) => h.changeType === type).length;
@ -132,7 +133,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
return (
<button
key={type}
onClick={() => setSelectedType(isActive ? '전체' : type)}
onClick={() => setSelectedType(isActive ? 'ALL' : type)}
className="px-3 py-1 rounded-full text-[11px] font-bold transition-all border"
style={{
background: isActive ? hex : undefined,
@ -147,7 +148,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
</div>
<div className="text-xs text-wing-muted">
: <strong className="text-wing-text">{filtered.length}</strong> |
{t(lang, 'showing')} <strong className="text-wing-text">{filtered.length}</strong>{t(lang, 'unit')} {t(lang, 'sortLatest')}
</div>
{/* 타임라인 목록 */}
@ -156,7 +157,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-slate-900 text-white">
{['날짜', '변경 유형', '설명'].map((h) => (
{[t(lang, 'colDate'), t(lang, 'colChangeType'), t(lang, 'colDescription')].map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
@ -200,7 +201,7 @@ export default function MethodologyTab({ lang }: MethodologyTabProps) {
{filtered.length === 0 && (
<div className="text-center py-12 text-wing-muted text-sm">
.
{t(lang, 'noMethodologyHistory')}
</div>
)}
</div>

파일 보기

@ -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) {
<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 className="text-sm">{t(lang, 'loading')}</div>
</div>
</div>
);
@ -84,7 +85,7 @@ export default function RiskTab({ lang }: RiskTabProps) {
if (error) {
return (
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
<strong> :</strong> {error}
<strong>{t(lang, 'loadError')}</strong> {error}
</div>
);
}

파일 보기

@ -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<string, TextMap> = { 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;
}

파일 보기

@ -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 (
<div className="space-y-6">
{/* 헤더 + 언어 토글 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">Risk & Compliance Change History</h1>
<h1 className="text-2xl font-bold text-wing-text">{t(lang, 'changeHistoryTitle')}</h1>
<p className="mt-1 text-sm text-wing-muted">
S&P
{t(lang, 'changeHistorySubtitle')}
</p>
</div>
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">

파일 보기

@ -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<ActiveTab>('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<ActiveTab>(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 (
<div className="space-y-6">
@ -21,10 +34,10 @@ export default function ScreeningGuide() {
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">
Risk & Compliance Screening Guide
{t(lang, 'screeningGuideTitle')}
</h1>
<p className="mt-1 text-sm text-wing-muted">
S&P Risk Indicators and Regulatory Compliance Screening Guide
{t(lang, 'screeningGuideSubtitle')}
</p>
</div>
{/* 언어 토글 */}
@ -48,7 +61,7 @@ export default function ScreeningGuide() {
{/* 언더라인 탭 */}
<div className="border-b border-wing-border">
<div className="flex gap-6">
{TABS.map((tab) => (
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
@ -65,8 +78,9 @@ export default function ScreeningGuide() {
</div>
{/* 탭 내용 */}
{activeTab === 'compliance' && <ComplianceTab lang={lang} />}
{activeTab === 'risk' && <RiskTab lang={lang} />}
{activeTab === 'ship-compliance' && <ComplianceTab lang={lang} indicatorType="SHIP" />}
{activeTab === 'company-compliance' && <ComplianceTab lang={lang} indicatorType="COMPANY" />}
{activeTab === 'methodology' && <MethodologyTab lang={lang} />}
</div>
);

파일 보기

@ -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<Server> 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("""

파일 보기

@ -77,6 +77,7 @@ logging:
# Custom Application Properties
app:
environment: prod
batch:
chunk-size: 1000
schedule: