Merge pull request 'feat(screening): Risk & Compliance 다국어 지원 및 사용자 편의성 개선 (#134)' (#136) from feature/ISSUE-134-risk-compliance-ux into develop

This commit is contained in:
HYOJIN 2026-04-02 11:16:30 +09:00
커밋 06f82df33a
8개의 변경된 파일382개의 추가작업 그리고 134개의 파일을 삭제

파일 보기

@ -4,6 +4,14 @@
## [Unreleased] ## [Unreleased]
### 추가
- Risk & Compliance 사용자 편의성 개선 (#134)
- UI 고정 텍스트 다국어 지원 (EN/KO 언어 토글 연동)
- -999/null 값 'No Data'/'데이터 없음' 표시 처리
- Screening Guide 탭 분리 (Ship Compliance / Company Compliance)
- Change History ↔ Screening Guide 간 언어 설정 공유 (localStorage)
- 섹션 헤더에 Screening Guide 연결 링크 추가
## [2026-04-01] ## [2026-04-01]
### 추가 ### 추가

파일 보기

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

파일 보기

@ -1,5 +1,7 @@
import { useState, useMemo } from 'react'; 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 { 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'; type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
@ -7,32 +9,34 @@ interface HistoryTabProps {
lang: string; lang: string;
} }
const HISTORY_TYPES: { interface HistoryTypeConfig {
key: HistoryType; key: HistoryType;
label: string; labelKey: string;
searchLabel: string; searchLabelKey: string;
searchPlaceholder: string; searchPlaceholderKey: string;
overallColumn: string | null; overallColumn: string | null;
}[] = [ }
const HISTORY_TYPES: HistoryTypeConfig[] = [
{ {
key: 'ship-risk', key: 'ship-risk',
label: '선박 위험지표', labelKey: 'histTypeShipRisk',
searchLabel: 'IMO 번호', searchLabelKey: 'searchLabelImo',
searchPlaceholder: 'IMO 번호 : 9672533', searchPlaceholderKey: 'searchPlaceholderImo',
overallColumn: null, overallColumn: null,
}, },
{ {
key: 'ship-compliance', key: 'ship-compliance',
label: '선박 제재', labelKey: 'histTypeShipCompliance',
searchLabel: 'IMO 번호', searchLabelKey: 'searchLabelImo',
searchPlaceholder: 'IMO 번호 : 9672533', searchPlaceholderKey: 'searchPlaceholderImo',
overallColumn: 'lgl_snths_sanction', overallColumn: 'lgl_snths_sanction',
}, },
{ {
key: 'company-compliance', key: 'company-compliance',
label: '회사 제재', labelKey: 'histTypeCompanyCompliance',
searchLabel: '회사 코드', searchLabelKey: 'searchLabelCompany',
searchPlaceholder: '회사 코드 : 1288896', searchPlaceholderKey: 'searchPlaceholderCompany',
overallColumn: 'company_snths_compliance_status', overallColumn: 'company_snths_compliance_status',
}, },
]; ];
@ -62,8 +66,19 @@ const STATUS_LABELS: Record<string, string> = {
'2': 'Severe', '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 (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]; const status = STATUS_MAP[value];
if (!status) return <span className="text-xs text-wing-muted">{value}</span>; if (!status) return <span className="text-xs text-wing-muted">{value}</span>;
return ( return (
@ -80,8 +95,16 @@ function countryFlag(code: string | null | undefined): string {
return String.fromCodePoint(...codePoints); 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 (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 color = STATUS_COLORS[value] ?? '#6b7280';
const label = narrative || STATUS_LABELS[value] || value; const label = narrative || STATUS_LABELS[value] || value;
return ( return (
@ -98,7 +121,8 @@ const COMPLIANCE_STATUS: Record<string, { label: string; className: string }> =
'2': { label: 'Yes', className: 'bg-red-100 text-red-800' }, '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 // IUU Fishing: All Clear -> None recorded
if (item.columnName === 'ilgl_fshr_viol' && item.value === '0') return 'None recorded'; if (item.columnName === 'ilgl_fshr_viol' && item.value === '0') return 'None recorded';
// Risk Data Maintained: 0 -> Yes, 1 -> Not Maintained // 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 || '-'; 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)이면 해당 지표만 표시 // Risk Data Maintained 체크: Not Maintained(1)이면 해당 지표만 표시
const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint'); const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint');
const isNotMaintained = riskDataMaintained?.value === '1'; const isNotMaintained = riskDataMaintained?.value === '1';
@ -132,7 +156,7 @@ function RiskStatusGrid({ items }: { items: IndicatorStatusResponse[] }) {
<div> <div>
{isNotMaintained && ( {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"> <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>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <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"> <div className="space-y-1.5">
{catItems.map((item) => { {catItems.map((item) => {
const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280'; const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280';
const label = getRiskLabel(item); const label = getRiskLabel(item, lang);
return ( return (
<div key={item.columnName} className="flex items-center gap-2 text-xs"> <div key={item.columnName} className="flex items-center gap-2 text-xs">
<span className="text-wing-text truncate flex-1">{item.fieldName}</span> <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: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' },
{ key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' }, { key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' },
{ key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' }, { key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' },
{ key: 'ownership', label: 'Ownership Screening', match: (code) => code === 'OWNERSHIP_SCREENING' },
]; ];
// Compliance 예외 처리 // Compliance 예외 처리
@ -189,13 +212,16 @@ function getComplianceLabel(item: IndicatorStatusResponse, isCompany: boolean):
const SHIP_COMPLIANCE_EXCLUDE = ['lgl_snths_sanction']; // Overall은 토글 헤더에 표시 const SHIP_COMPLIANCE_EXCLUDE = ['lgl_snths_sanction']; // Overall은 토글 헤더에 표시
const COMPANY_COMPLIANCE_EXCLUDE = ['company_snths_compliance_status']; // Overall은 토글 헤더에 표시 const COMPANY_COMPLIANCE_EXCLUDE = ['company_snths_compliance_status']; // Overall은 토글 헤더에 표시
function ComplianceStatusItem({ item, isCompany }: { item: IndicatorStatusResponse; isCompany: boolean }) { function ComplianceStatusItem({ item, isCompany, lang = 'EN' }: { item: IndicatorStatusResponse; isCompany: boolean; lang?: string }) {
const overrideLabel = getComplianceLabel(item, isCompany); const isNoData = isNoDataValue(item.value);
const overrideLabel = isNoData ? t(lang, 'noData') : getComplianceLabel(item, isCompany);
const status = overrideLabel ? null : COMPLIANCE_STATUS[item.value ?? '']; const status = overrideLabel ? null : COMPLIANCE_STATUS[item.value ?? ''];
const displayLabel = overrideLabel || (status ? status.label : (item.value ?? '-')); const displayLabel = overrideLabel || (status ? status.label : (item.value ?? '-'));
const displayClass = overrideLabel const displayClass = isNoData
? 'bg-wing-surface text-wing-muted border border-wing-border' ? 'bg-gray-100 text-gray-500 border border-gray-300'
: status ? status.className : 'bg-wing-surface text-wing-muted border border-wing-border'; : 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 ( return (
<div className="flex items-center gap-2 text-xs"> <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'); const [activeTab, setActiveTab] = useState('sanctions');
// 제외 항목 필터링 // 제외 항목 필터링
@ -240,7 +266,7 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
{catItems.map((item) => ( {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>
@ -302,14 +328,14 @@ function ComplianceStatusGrid({ items, isCompany }: { items: IndicatorStatusResp
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
{catItems.map((item) => ( {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>
))} ))}
</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> </div>
); );
@ -329,7 +355,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
const [complianceStatusCache, setComplianceStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({}); const [complianceStatusCache, setComplianceStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['info', 'risk', 'compliance', 'history'])); 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 isRisk = historyType === 'ship-risk';
const data = cache[lang] ?? []; const data = cache[lang] ?? [];
const riskStatus = riskStatusCache[lang] ?? []; const riskStatus = riskStatusCache[lang] ?? [];
@ -465,17 +491,17 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
{/* 이력 유형 선택 (언더라인 탭) */} {/* 이력 유형 선택 (언더라인 탭) */}
<div className="border-b border-wing-border"> <div className="border-b border-wing-border">
<div className="flex gap-6"> <div className="flex gap-6">
{HISTORY_TYPES.map((t) => ( {HISTORY_TYPES.map((ht) => (
<button <button
key={t.key} key={ht.key}
onClick={() => handleTypeChange(t.key)} onClick={() => handleTypeChange(ht.key)}
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${ 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-blue-600 border-blue-600'
: 'text-wing-muted border-transparent hover:text-wing-text' : 'text-wing-muted border-transparent hover:text-wing-text'
}`} }`}
> >
{t.label} {t(lang, ht.labelKey)}
</button> </button>
))} ))}
</div> </div>
@ -499,7 +525,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
value={searchValue} value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={handleKeyDown} 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 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" 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} 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" 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> </button>
</div> </div>
{/* 에러 */} {/* 에러 */}
{error && ( {error && (
<div className="bg-red-50 border border-red-300 rounded-xl p-4 text-red-800 text-sm"> <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> </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-xs transition-transform ${expandedSections.has('info') ? 'rotate-90' : ''}`}>&#9654;</span>
<span className="text-sm font-semibold text-wing-text"> <span className="text-sm font-semibold text-wing-text">
{shipInfo ? '선박 기본 정보' : '회사 기본 정보'} {shipInfo ? t(lang, 'shipBasicInfo') : t(lang, 'companyBasicInfo')}
</span> </span>
</button> </button>
{expandedSections.has('info') && ( {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-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex items-center gap-2"> <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"> <span className="font-medium text-wing-text">
{countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'} {countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text">{shipInfo.shipType || '-'}</span>
</div> </div>
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text">{shipInfo.gt || '-'}</span>
</div> </div>
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text">{shipInfo.buildYear || '-'}</span>
</div> </div>
</div> </div>
@ -621,7 +647,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
</div> </div>
{companyInfo.parentCompanyName && ( {companyInfo.parentCompanyName && (
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text">{companyInfo.parentCompanyName}</span>
</div> </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-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex items-center gap-2"> <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"> <span className="font-medium text-wing-text">
{countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'} {countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'}
</span> </span>
</div> </div>
{companyInfo.controlCountry && ( {companyInfo.controlCountry && (
<div className="flex items-center gap-2"> <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"> <span className="font-medium text-wing-text">
{countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry} {countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry}
</span> </span>
@ -649,25 +675,25 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
)} )}
{companyInfo.foundedDate && ( {companyInfo.foundedDate && (
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text">{companyInfo.foundedDate}</span>
</div> </div>
)} )}
{companyInfo.email && ( {companyInfo.email && (
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text truncate">{companyInfo.email}</span>
</div> </div>
)} )}
{companyInfo.phone && ( {companyInfo.phone && (
<div className="flex items-center gap-2"> <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> <span className="font-medium text-wing-text">{companyInfo.phone}</span>
</div> </div>
)} )}
{companyInfo.website && ( {companyInfo.website && (
<div className="flex items-center gap-2"> <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 <a
href={companyInfo.website.startsWith('http') ? companyInfo.website : `https://${companyInfo.website}`} href={companyInfo.website.startsWith('http') ? companyInfo.website : `https://${companyInfo.website}`}
target="_blank" target="_blank"
@ -689,16 +715,24 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
{/* Section 2: Current Risk Indicators (선박 탭만) */} {/* Section 2: Current Risk Indicators (선박 탭만) */}
{riskStatus.length > 0 && ( {riskStatus.length > 0 && (
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden"> <div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<button <div className="flex items-center">
onClick={() => toggleSection('risk')} <button
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors" 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">Current Risk Indicators</span> <span className={`text-xs transition-transform ${expandedSections.has('risk') ? 'rotate-90' : ''}`}>&#9654;</span>
</button> <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') && ( {expandedSections.has('risk') && (
<div className="border-t border-wing-border px-4 py-4"> <div className="border-t border-wing-border px-4 py-4">
<RiskStatusGrid items={riskStatus} /> <RiskStatusGrid items={riskStatus} lang={lang} />
</div> </div>
)} )}
</div> </div>
@ -712,19 +746,27 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
: null; : null;
return ( return (
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden"> <div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<button <div className="flex items-center">
onClick={() => toggleSection('compliance')} <button
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors" 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">Current Compliance</span> <span className={`text-xs transition-transform ${expandedSections.has('compliance') ? 'rotate-90' : ''}`}>&#9654;</span>
{overallItem && ( <span className="text-sm font-semibold text-wing-text">{t(lang, 'currentCompliance')}</span>
<StatusBadge value={overallItem.value} /> {overallItem && (
)} <StatusBadge value={overallItem.value} lang={lang} />
</button> )}
</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') && ( {expandedSections.has('compliance') && (
<div className="border-t border-wing-border px-4 py-4"> <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>
)} )}
</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" 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-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"> <span className="text-xs text-wing-muted">
{grouped.length} , {data.length} {grouped.length}{t(lang, 'dateCount')}, {data.length}{t(lang, 'changeCount')}
</span> </span>
</button> </button>
{expandedSections.has('history') && ( {expandedSections.has('history') && (
@ -779,19 +821,19 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
</span> </span>
{hasOverall ? ( {hasOverall ? (
<div className="flex items-center gap-1.5"> <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> <span className="text-xs text-wing-muted"></span>
<StatusBadge value={group.overallAfter} /> <StatusBadge value={group.overallAfter} lang={lang} />
</div> </div>
) : ( ) : (
<span className="text-xs text-wing-muted"> <span className="text-xs text-wing-muted">
{displayItems.length} {displayItems.length}{t(lang, 'changeCount')}
</span> </span>
)} )}
</div> </div>
{hasOverall && ( {hasOverall && (
<span className="text-xs text-wing-muted"> <span className="text-xs text-wing-muted">
{displayItems.length} {displayItems.length}{t(lang, 'changeCount')}
</span> </span>
)} )}
</button> </button>
@ -804,19 +846,19 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
style={{ width: '50%' }} style={{ width: '50%' }}
className="px-4 py-2 text-center font-semibold" className="px-4 py-2 text-center font-semibold"
> >
{t(lang, 'colFieldName')}
</th> </th>
<th <th
style={{ width: '25%' }} style={{ width: '25%' }}
className="px-4 py-2 text-center font-semibold" className="px-4 py-2 text-center font-semibold"
> >
{t(lang, 'colBefore')}
</th> </th>
<th <th
style={{ width: '25%' }} style={{ width: '25%' }}
className="px-4 py-2 text-center font-semibold" className="px-4 py-2 text-center font-semibold"
> >
{t(lang, 'colAfter')}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -845,7 +887,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
style={{ width: '25%' }} style={{ width: '25%' }}
className="px-4 py-2.5 text-center" 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>
<td <td
style={{ width: '25%' }} style={{ width: '25%' }}
@ -854,6 +896,7 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
<RiskValueCell <RiskValueCell
value={row.afterValue} value={row.afterValue}
narrative={row.narrative ?? undefined} narrative={row.narrative ?? undefined}
lang={lang}
/> />
</td> </td>
</> </>
@ -863,13 +906,13 @@ export default function HistoryTab({ lang }: HistoryTabProps) {
style={{ width: '25%' }} style={{ width: '25%' }}
className="px-4 py-2.5 text-center" className="px-4 py-2.5 text-center"
> >
<StatusBadge value={row.beforeValue} /> <StatusBadge value={row.beforeValue} lang={lang} />
</td> </td>
<td <td
style={{ width: '25%' }} style={{ width: '25%' }}
className="px-4 py-2.5 text-center" className="px-4 py-2.5 text-center"
> >
<StatusBadge value={row.afterValue} /> <StatusBadge value={row.afterValue} lang={lang} />
</td> </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="flex items-center justify-center py-8 text-wing-muted">
<div className="text-center"> <div className="text-center">
<div className="text-xl mb-1">📭</div> <div className="text-xl mb-1">📭</div>
<div className="text-sm"> .</div> <div className="text-sm">{t(lang, 'noChangeHistory')}</div>
</div> </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="flex items-center justify-center py-16 text-wing-muted">
<div className="text-center"> <div className="text-center">
<div className="text-3xl mb-3">🔍</div> <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>
</div> </div>
)} )}

파일 보기

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

파일 보기

@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi'; import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
import { t } from '../../constants/screeningTexts';
interface RiskTabProps { interface RiskTabProps {
lang: string; 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="flex items-center justify-center py-20 text-wing-muted">
<div className="text-center"> <div className="text-center">
<div className="text-2xl mb-2"></div> <div className="text-2xl mb-2"></div>
<div className="text-sm"> ...</div> <div className="text-sm">{t(lang, 'loading')}</div>
</div> </div>
</div> </div>
); );
@ -84,7 +85,7 @@ export default function RiskTab({ lang }: RiskTabProps) {
if (error) { if (error) {
return ( return (
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm"> <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> </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 HistoryTab from '../components/screening/HistoryTab';
import { t } from '../constants/screeningTexts';
const LANG_KEY = 'screening-lang';
export default function RiskComplianceHistory() { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 헤더 + 언어 토글 */} {/* 헤더 + 언어 토글 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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"> <p className="mt-1 text-sm text-wing-muted">
S&P {t(lang, 'changeHistorySubtitle')}
</p> </p>
</div> </div>
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0"> <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 RiskTab from '../components/screening/RiskTab';
import ComplianceTab from '../components/screening/ComplianceTab'; import ComplianceTab from '../components/screening/ComplianceTab';
import MethodologyTab from '../components/screening/MethodologyTab'; import MethodologyTab from '../components/screening/MethodologyTab';
import { t } from '../constants/screeningTexts';
type ActiveTab = 'compliance' | 'risk' | 'methodology'; type ActiveTab = 'risk' | 'ship-compliance' | 'company-compliance' | 'methodology';
const VALID_TABS: ActiveTab[] = ['risk', 'ship-compliance', 'company-compliance', 'methodology'];
const TABS: { key: ActiveTab; label: string }[] = [ const LANG_KEY = 'screening-lang';
{ key: 'compliance', label: 'Compliance' },
{ key: 'risk', label: 'Risk Indicators' },
{ key: 'methodology', label: 'Methodology History' },
];
export default function ScreeningGuide() { export default function ScreeningGuide() {
const [activeTab, setActiveTab] = useState<ActiveTab>('compliance'); const [searchParams] = useSearchParams();
const [lang, setLang] = useState('EN'); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -21,10 +34,10 @@ export default function ScreeningGuide() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-wing-text"> <h1 className="text-2xl font-bold text-wing-text">
Risk & Compliance Screening Guide {t(lang, 'screeningGuideTitle')}
</h1> </h1>
<p className="mt-1 text-sm text-wing-muted"> <p className="mt-1 text-sm text-wing-muted">
S&P Risk Indicators and Regulatory Compliance Screening Guide {t(lang, 'screeningGuideSubtitle')}
</p> </p>
</div> </div>
{/* 언어 토글 */} {/* 언어 토글 */}
@ -48,7 +61,7 @@ export default function ScreeningGuide() {
{/* 언더라인 탭 */} {/* 언더라인 탭 */}
<div className="border-b border-wing-border"> <div className="border-b border-wing-border">
<div className="flex gap-6"> <div className="flex gap-6">
{TABS.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.key} key={tab.key}
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
@ -65,8 +78,9 @@ export default function ScreeningGuide() {
</div> </div>
{/* 탭 내용 */} {/* 탭 내용 */}
{activeTab === 'compliance' && <ComplianceTab lang={lang} />}
{activeTab === 'risk' && <RiskTab 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} />} {activeTab === 'methodology' && <MethodologyTab lang={lang} />}
</div> </div>
); );