snp-connection-monitoring/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx
HYOJIN c2a71c1b77 feat(design): 디자인 시스템 적용 (CSS 토큰, Button/Badge, 차트, 다크모드) (#48)
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/)
- CSS 변수 토큰 시스템 (@theme + :root/.dark 전환)
- cn() 유틸리티 (clsx + tailwind-merge)
- Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응)
- 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일)
- 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX)
- 버튼 다크모드 채도/대비 강화 (primary-600 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:38:00 +09:00

496 lines
22 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { ServiceCatalog } from '../../types/apihub';
import type { ApiDetailInfo } from '../../types/service';
import { getServiceCatalog, getApiHubApiDetail } from '../../services/apiHubService';
import { getSystemConfig } from '../../services/configService';
import { useApiRequestBasket } from '../../hooks/useApiRequestBasket';
import ApiKeyRequestModal from '../../components/ApiKeyRequestModal';
import Button from '../../components/ui/Button';
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
const METHOD_COLORS_LARGE: Record<string, string> = {
GET: 'bg-green-500',
POST: 'bg-blue-500',
PUT: 'bg-amber-500',
PATCH: 'bg-amber-500',
DELETE: 'bg-red-500',
};
const ApiHubApiDetailPage = () => {
const { serviceId, apiId } = useParams<{ serviceId: string; apiId: string }>();
const navigate = useNavigate();
const { addItem, removeItem, hasItem, setBasketOpen } = useApiRequestBasket();
const [detail, setDetail] = useState<ApiDetailInfo | null>(null);
const [service, setService] = useState<ServiceCatalog | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paramInputs, setParamInputs] = useState<Record<string, string>>({});
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
const [urlCopied, setUrlCopied] = useState(false);
const [validationErrors, setValidationErrors] = useState<Record<string, boolean>>({});
const [commonSampleCode, setCommonSampleCode] = useState<string | null>(null);
const [urlGenOpen, setUrlGenOpen] = useState(false);
// 신청 모달 상태
const [showRequestModal, setShowRequestModal] = useState(false);
const [requestModalApiIds, setRequestModalApiIds] = useState<number[]>([]);
const [requestModalApiNames, setRequestModalApiNames] = useState<string[]>([]);
const fetchData = useCallback(async () => {
if (!serviceId || !apiId) return;
try {
const [serviceRes, detailRes, sampleCodeRes] = await Promise.all([
getServiceCatalog(Number(serviceId)),
getApiHubApiDetail(Number(serviceId), Number(apiId)),
getSystemConfig(COMMON_SAMPLE_CODE_KEY),
]);
if (serviceRes.success && serviceRes.data) {
setService(serviceRes.data);
}
if (detailRes.success && detailRes.data) {
setDetail(detailRes.data);
} else {
setError('API 정보를 불러오지 못했습니다');
}
if (sampleCodeRes.success && sampleCodeRes.data?.configValue) {
setCommonSampleCode(sampleCodeRes.data.configValue);
}
} catch {
setError('API 정보를 불러오는 중 오류가 발생했습니다');
} finally {
setIsLoading(false);
}
}, [serviceId, apiId]);
useEffect(() => {
setParamInputs({});
setGeneratedUrl(null);
setUrlCopied(false);
setValidationErrors({});
setIsLoading(true);
setError(null);
fetchData();
}, [fetchData]);
const urlInputParams = useMemo(() =>
detail?.requestParams.filter((p) => p.paramName.toUpperCase() !== 'URL') ?? [],
[detail?.requestParams]
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-[var(--color-text-secondary)]"> ...</div>
</div>
);
}
if (error || !detail) {
return (
<div>
<button
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] mb-4 inline-block"
>
</button>
<div className="text-center py-20 text-[var(--color-text-secondary)]">
{error ?? 'API를 찾을 수 없습니다'}
</div>
</div>
);
}
const api = detail.api;
const spec = detail.spec;
const domainLabel = api.apiDomain ? api.apiDomain.toUpperCase() : '기타';
const handleGenerateUrl = () => {
const errors: Record<string, boolean> = {};
for (const p of urlInputParams) {
if (p.required && !paramInputs[p.paramName]?.trim()) {
errors[p.paramName] = true;
}
}
setValidationErrors(errors);
if (Object.keys(errors).length > 0) return;
const base = spec?.sampleUrl || (service?.serviceUrl ? `${service.serviceUrl}${api.apiPath}` : api.apiPath);
const queryParts: string[] = [];
for (const p of urlInputParams) {
const val = paramInputs[p.paramName];
if (val) {
const type = (p.inputType || 'TEXT').toUpperCase();
let formatted = val;
if (type === 'DATE' || type === 'DATETIME') {
formatted = new Date(val).toISOString();
}
queryParts.push(`${p.paramName}=${formatted}`);
}
}
const url = queryParts.length > 0
? `${base}${base.includes('?') ? '&' : '?'}${queryParts.join('&')}`
: base;
setGeneratedUrl(url);
setUrlCopied(false);
};
const handleCopyGeneratedUrl = () => {
if (generatedUrl) {
navigator.clipboard.writeText(generatedUrl);
setUrlCopied(true);
setTimeout(() => setUrlCopied(false), 2000);
}
};
const handleOpenRequest = () => {
if (detail) {
setRequestModalApiIds([detail.api.apiId]);
setRequestModalApiNames([detail.api.apiName]);
setShowRequestModal(true);
}
};
return (
<div className="max-w-7xl mx-auto">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] mb-4">
<button
onClick={() => navigate('/api-hub')}
className="hover:text-[var(--color-primary)] transition-colors"
>
API HUB
</button>
<span>/</span>
<button
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(domainLabel)}`)}
className="hover:text-[var(--color-primary)] transition-colors"
>
{domainLabel}
</button>
<span>/</span>
<span className="text-[var(--color-text-primary)] font-medium truncate max-w-xs">{api.apiName}</span>
</nav>
{/* Header Card */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 border border-[var(--color-border)]">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<span
className={`px-3 py-1.5 rounded text-sm font-bold uppercase text-white shrink-0 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
>
{api.apiMethod}
</span>
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">{api.apiName}</h1>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasItem(Number(apiId)) ? (
<button
onClick={() => removeItem(Number(apiId))}
className="px-4 py-2 rounded-lg border border-indigo-300 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 text-sm font-medium transition-colors flex items-center gap-1.5"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
) : (
<button
onClick={() => {
if (detail) {
addItem({ apiId: detail.api.apiId, serviceId: Number(serviceId), apiName: detail.api.apiName, domain: detail.api.apiDomain ?? undefined });
setBasketOpen(true);
}
}}
className="px-4 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] text-sm font-medium transition-colors flex items-center gap-1.5"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</button>
)}
<Button onClick={handleOpenRequest}>
API
</Button>
</div>
</div>
</div>
<div className="space-y-6">
{/* 기본 정보 */}
{api.description && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4"> </h2>
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{api.description}</p>
</div>
)}
{/* 샘플 URL */}
{spec?.sampleUrl && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3"> URL</h2>
<a
href={spec.sampleUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] break-all underline underline-offset-2 font-mono"
>
{spec.sampleUrl}
</a>
</div>
)}
{/* 요청 URL 생성 (아코디언) */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
<button
type="button"
onClick={() => setUrlGenOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 text-left"
>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]"> URL </h2>
<svg
className={`w-5 h-5 text-[var(--color-text-secondary)] transition-transform duration-200 ${urlGenOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{urlGenOpen && (
<div className="px-6 pb-6 border-t border-[var(--color-border)] pt-4">
{/* URL 생성 폼 */}
{urlInputParams.length > 0 && (
<div className="mb-4">
<div className="space-y-3 mb-4">
{urlInputParams.map((p) => {
const hasError = validationErrors[p.paramName];
const inputCls = `flex-1 border ${hasError ? 'border-red-400' : 'border-[var(--color-border-strong)]'} bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-[var(--color-primary)] focus:outline-none`;
const type = (p.inputType || 'TEXT').toUpperCase();
return (
<div key={p.paramName}>
<div className="flex items-center gap-3">
<label className="w-36 shrink-0 text-sm font-medium text-[var(--color-text-secondary)]">
{p.paramName}
{p.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{type === 'DATE' || type === 'DATETIME' ? (
<input
type={type === 'DATETIME' ? 'datetime-local' : 'date'}
value={paramInputs[p.paramName] || ''}
onChange={(e) => {
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
}}
className={inputCls}
/>
) : type === 'NUMBER' ? (
<input
type="number"
value={paramInputs[p.paramName] || ''}
onChange={(e) => {
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
}}
placeholder={p.paramMeaning || p.paramName}
className={inputCls}
/>
) : (
<input
type="text"
value={paramInputs[p.paramName] || ''}
onChange={(e) => {
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
}}
placeholder={p.paramMeaning || p.paramName}
className={inputCls}
/>
)}
</div>
{hasError && (
<p className="ml-[9.75rem] mt-1 text-xs text-red-500">{p.paramName}() </p>
)}
</div>
);
})}
</div>
<Button onClick={handleGenerateUrl}>
URL
</Button>
</div>
)}
{/* 생성된 URL */}
{generatedUrl && (
<div className="mb-4">
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5"> URL</p>
<div className="flex items-center p-3 bg-[var(--color-bg-base)] rounded-lg">
<span
className={`px-2 py-1 rounded text-xs font-bold uppercase text-white shrink-0 mr-3 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
>
{api.apiMethod}
</span>
<span className="font-mono text-sm text-[var(--color-text-primary)] break-all flex-1">
{generatedUrl}
</span>
<button
onClick={handleCopyGeneratedUrl}
className="ml-2 px-2 py-0.5 text-xs bg-[var(--color-bg-base)] text-[var(--color-text-tertiary)] rounded hover:bg-[var(--color-border)] transition-colors"
>
{urlCopied ? '복사됨' : '복사'}
</button>
</div>
</div>
)}
{/* 샘플 코드 */}
{commonSampleCode && (
<div>
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5"> </p>
<pre className="bg-gray-900 text-green-400 text-sm font-mono p-4 rounded-lg overflow-x-auto">
{commonSampleCode}
</pre>
</div>
)}
{urlInputParams.length === 0 && !commonSampleCode && (
<p className="text-sm text-[var(--color-text-tertiary)]"> </p>
)}
</div>
)}
</div>
{/* 요청인자 */}
{detail.requestParams.length > 0 && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
<div className="px-6 py-4 border-b border-[var(--color-border)]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]"></h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{detail.requestParams.map((param) => (
<tr key={param.paramId} className="hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
{param.paramName}
{param.required && <span className="text-red-500 ml-0.5">*</span>}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{param.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
</td>
<td className="px-4 py-3 text-[var(--color-text-tertiary)] max-w-xs">
{param.paramDescription ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 출력결과 */}
{detail.responseParams.length > 0 && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
<div className="px-6 py-4 border-b border-[var(--color-border)]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]"></h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">()</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">()</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{Array.from({ length: Math.ceil(detail.responseParams.length / 2) }, (_, rowIdx) => {
const left = detail.responseParams[rowIdx * 2];
const right = detail.responseParams[rowIdx * 2 + 1];
return (
<tr key={rowIdx} className="hover:bg-[var(--color-bg-base)] transition-colors">
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
{left.paramName}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{left.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
</td>
{right ? (
<>
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
{right.paramName}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{right.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
</td>
</>
) : (
<>
<td className="px-4 py-3" />
<td className="px-4 py-3" />
</>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* 참고자료 */}
{spec?.referenceUrl && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3"></h2>
<a
href={spec.referenceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] break-all underline underline-offset-2"
>
{spec.referenceUrl}
</a>
</div>
)}
{/* 비고 */}
{spec?.note && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3"></h2>
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">
{spec.note}
</p>
</div>
)}
</div>
<ApiKeyRequestModal
isOpen={showRequestModal}
onClose={() => setShowRequestModal(false)}
initialApiIds={requestModalApiIds}
initialApiNames={requestModalApiNames}
/>
</div>
);
};
export default ApiHubApiDetailPage;