snp-batch-validation/frontend/src/pages/BypassCatalog.tsx
HYOJIN ad18ab9c30 feat(email): Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)
- 이메일 공통 모듈 (spring-boot-starter-mail, EmailService, Thymeleaf 템플릿)
- 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송
- 재심사 기능 (REJECTED → PENDING)
- UI 텍스트 리레이블링 (S&P Global API)
- 신청 폼 전화번호 필드 제거 및 레이아웃 개선
2026-04-03 10:34:45 +09:00

321 lines
17 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
interface BypassParam {
paramName: string;
paramType: string;
paramIn: string;
required: boolean;
description: string;
example: string;
}
interface BypassConfig {
id: number;
domainName: string;
endpointName: string;
displayName: string;
httpMethod: string;
externalPath: string;
description: string;
generated: boolean;
createdAt: string;
params: BypassParam[];
}
interface ApiResponse<T> {
success: boolean;
data: T;
}
type ViewMode = 'card' | 'table';
const METHOD_COLORS: Record<string, string> = {
GET: 'bg-emerald-100 text-emerald-700',
POST: 'bg-blue-100 text-blue-700',
PUT: 'bg-amber-100 text-amber-700',
DELETE: 'bg-red-100 text-red-700',
};
const SWAGGER_BASE = '/snp-api/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API';
function buildSwaggerDeepLink(config: BypassConfig): string {
// Swagger UI deep link: #/{Tag}/{operationId}
// Tag = domainName 첫글자 대문자 (예: compliance → Compliance)
// operationId = get{EndpointName}Data (SpringDoc 기본 패턴)
const tag = config.domainName.charAt(0).toUpperCase() + config.domainName.slice(1);
const operationId = `get${config.endpointName}Data`;
return `${SWAGGER_BASE}#/${tag}/${operationId}`;
}
export default function BypassCatalog() {
const [configs, setConfigs] = useState<BypassConfig[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedDomain, setSelectedDomain] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('table');
useEffect(() => {
fetch('/snp-api/api/bypass-config')
.then(res => res.json())
.then((res: ApiResponse<BypassConfig[]>) => setConfigs((res.data ?? []).filter(c => c.generated)))
.catch(() => setConfigs([]))
.finally(() => setLoading(false));
}, []);
const domainNames = useMemo(() => {
const names = [...new Set(configs.map((c) => c.domainName))];
return names.sort();
}, [configs]);
const filtered = useMemo(() => {
return configs.filter((c) => {
const matchesSearch =
!searchTerm.trim() ||
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
return matchesSearch && matchesDomain;
});
}, [configs, searchTerm, selectedDomain]);
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-wing-muted">
<div className="text-sm">API ...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">S&P Global API </h1>
<p className="mt-1 text-sm text-wing-muted">
S&P Global Maritime API . Swagger UI에서 .
</p>
</div>
<a
href={SWAGGER_BASE}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
>
Swagger UI
</a>
</div>
{/* 검색 + 필터 + 뷰 전환 */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="flex gap-3 items-center flex-wrap">
{/* 검색 */}
<div className="relative flex-1 min-w-[200px]">
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
type="text"
placeholder="도메인명, 표시명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* 도메인 드롭다운 필터 */}
<select
value={selectedDomain}
onChange={(e) => setSelectedDomain(e.target.value)}
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
>
<option value=""> </option>
{domainNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
{/* 뷰 전환 토글 */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
<button
onClick={() => setViewMode('table')}
title="테이블 보기"
className={`px-3 py-2 transition-colors ${
viewMode === 'table'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setViewMode('card')}
title="카드 보기"
className={`px-3 py-2 transition-colors border-l border-wing-border ${
viewMode === 'card'
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
</button>
</div>
</div>
{(searchTerm || selectedDomain) && (
<p className="mt-2 text-xs text-wing-muted">
{filtered.length} API
</p>
)}
</div>
{/* 빈 상태 */}
{configs.length === 0 ? (
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
<p className="text-base font-medium mb-1"> API가 .</p>
<p className="text-sm"> .</p>
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
<p className="text-base font-medium mb-1"> .</p>
<p className="text-sm"> .</p>
</div>
) : viewMode === 'card' ? (
/* 카드 뷰 */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((config) => (
<div
key={config.id}
className="bg-wing-card border border-wing-border rounded-xl p-5 flex flex-col gap-3 hover:border-wing-accent/40 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-wing-text truncate">{config.displayName}</p>
<p className="text-xs text-wing-muted font-mono mt-0.5">{config.domainName}</p>
</div>
<span className={['shrink-0 px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
{config.httpMethod}
</span>
</div>
<div className="space-y-1.5">
<p className="text-xs text-wing-muted font-mono truncate">{config.externalPath}</p>
{config.description && (
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
)}
</div>
{config.params.length > 0 && (
<div>
<div className="text-[10px] text-wing-muted mb-1 font-medium">Parameters</div>
<div className="flex flex-wrap gap-1">
{config.params.map((p) => (
<span
key={p.paramName}
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted"
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
>
{p.paramName}
{p.required && <span className="text-red-400 ml-0.5">*</span>}
</span>
))}
</div>
</div>
)}
<div className="pt-1 border-t border-wing-border mt-auto">
<a
href={buildSwaggerDeepLink(config)}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
>
Swagger에서
</a>
</div>
</div>
))}
</div>
) : (
/* 테이블 뷰 */
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">HTTP</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Swagger</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border">
{filtered.map((config) => (
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
<td className="px-4 py-3 font-mono text-xs text-wing-text">{config.domainName}</td>
<td className="px-4 py-3 font-medium text-wing-text">{config.displayName}</td>
<td className="px-4 py-3">
<span className={['px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
{config.httpMethod}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[250px] truncate" title={config.externalPath}>
{config.externalPath}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{config.params.map((p) => (
<span
key={p.paramName}
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-card text-wing-muted"
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
>
{p.paramName}
{p.required && <span className="text-red-400 ml-0.5">*</span>}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-right">
<a
href={buildSwaggerDeepLink(config)}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
>
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}