diff --git a/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx b/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx index 6ac80e2..f1cace9 100644 --- a/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx +++ b/frontend/src/pages/apihub/ApiHubApiDetailPage.tsx @@ -1,15 +1,12 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub'; -import { getCatalog } from '../../services/apiHubService'; +import type { ServiceCatalog } from '../../types/apihub'; +import type { ApiDetailInfo } from '../../types/service'; +import { getCatalog, getServiceCatalog, getApiHubApiDetail } from '../../services/apiHubService'; +import { getSystemConfig } from '../../services/configService'; +import { createKeyRequest } from '../../services/apiKeyService'; -const METHOD_COLORS: Record = { - GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', - POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', - PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', - PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', - DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', -}; +const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE'; const METHOD_COLORS_LARGE: Record = { GET: 'bg-green-500', @@ -19,55 +16,58 @@ const METHOD_COLORS_LARGE: Record = { DELETE: 'bg-red-500', }; -const formatDateTime = (dateStr: string): string => { - const d = new Date(dateStr); - const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; - return `${date} ${time}`; -}; - -interface LabelValueRowProps { - label: string; - value: React.ReactNode; -} - -const LabelValueRow = ({ label, value }: LabelValueRowProps) => ( -
-
{label}
-
{value}
-
-); - const ApiHubApiDetailPage = () => { const { serviceId, apiId } = useParams<{ serviceId: string; apiId: string }>(); const navigate = useNavigate(); - const [api, setApi] = useState(null); + const [detail, setDetail] = useState(null); const [service, setService] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [paramInputs, setParamInputs] = useState>({}); + const [generatedUrl, setGeneratedUrl] = useState(null); + const [urlCopied, setUrlCopied] = useState(false); + const [validationErrors, setValidationErrors] = useState>({}); + const [commonSampleCode, setCommonSampleCode] = useState(null); + const [urlGenOpen, setUrlGenOpen] = useState(false); + + // 신청 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalKeyName, setModalKeyName] = useState(''); + const [modalPurpose, setModalPurpose] = useState(''); + const [modalServiceIp, setModalServiceIp] = useState(''); + const [modalServicePurpose, setModalServicePurpose] = useState(''); + const [modalDailyRequestEstimate, setModalDailyRequestEstimate] = useState(''); + const [modalUsagePeriodMode, setModalUsagePeriodMode] = useState<'preset' | 'custom'>('preset'); + const [modalIsPermanent, setModalIsPermanent] = useState(false); + const [modalUsageFromDate, setModalUsageFromDate] = useState(''); + const [modalUsageToDate, setModalUsageToDate] = useState(''); + const [modalIsSubmitting, setModalIsSubmitting] = useState(false); + const [modalError, setModalError] = useState(null); + const [modalSuccess, setModalSuccess] = useState(false); + const [modalSelectedApiIds, setModalSelectedApiIds] = useState>(new Set()); + const [modalCatalog, setModalCatalog] = useState([]); + const [modalExpandedDomains, setModalExpandedDomains] = useState>(new Set()); + const [modalApiSearch, setModalApiSearch] = useState(''); const fetchData = useCallback(async () => { if (!serviceId || !apiId) return; try { - const res = await getCatalog(); - if (res.success && res.data) { - const foundService = res.data.find((s) => s.serviceId === Number(serviceId)); - if (foundService) { - setService(foundService); - const foundApi = foundService.domains - .flatMap((d) => d.apis) - .find((a) => a.apiId === Number(apiId)); - if (foundApi) { - setApi(foundApi); - } else { - setError('API를 찾을 수 없습니다'); - } - } else { - setError('서비스를 찾을 수 없습니다'); - } + 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 { @@ -76,9 +76,20 @@ const ApiHubApiDetailPage = () => { }, [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 (
@@ -87,7 +98,7 @@ const ApiHubApiDetailPage = () => { ); } - if (error || !api) { + if (error || !detail) { return (
-
+
+ {/* 기본 정보 */} -
-
-

기본 정보

-
- - - {api.apiMethod} - - } - /> - {api.apiPath}} - /> - - {api.apiDomain} - - ) : ( - - - ) - } - /> - - - ) - } - /> - - {api.isActive ? '활성' : '비활성'} - - } - /> - -
-
- - {/* 설명 */} -
-

설명

- {api.description ? ( -

- {api.description} -

- ) : ( -

설명이 없습니다

- )} -
- - {/* 요청 정보 */} + {api.description && (
-

요청 정보

-
- - {api.apiMethod} - - {api.apiPath} +

기본 정보

+

{api.description}

+
+ )} + + {/* 샘플 URL */} + {spec?.sampleUrl && ( +
+

샘플 URL

+ + {spec.sampleUrl} + +
+ )} + + {/* 요청 URL 생성 (아코디언) */} +
+ + + {urlGenOpen && ( +
+ {/* URL 생성 폼 */} + {urlInputParams.length > 0 && ( +
+
+ {urlInputParams.map((p) => { + const hasError = validationErrors[p.paramName]; + const inputCls = `flex-1 border ${hasError ? 'border-red-400 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none`; + const type = (p.inputType || 'TEXT').toUpperCase(); + + return ( +
+
+ + {type === 'DATE' || type === 'DATETIME' ? ( + { + setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value })); + if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false })); + }} + className={inputCls} + /> + ) : type === 'NUMBER' ? ( + { + setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value })); + if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false })); + }} + placeholder={p.paramMeaning || p.paramName} + className={inputCls} + /> + ) : ( + { + setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value })); + if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false })); + }} + placeholder={p.paramMeaning || p.paramName} + className={inputCls} + /> + )} +
+ {hasError && ( +

{p.paramName}은(는) 필수입니다

+ )} +
+ ); + })} +
+ +
+ )} + + {/* 생성된 URL */} + {generatedUrl && ( +
+

생성된 요청 URL

+
+ + {api.apiMethod} + + + {generatedUrl} + + +
+
+ )} + + {/* 샘플 코드 */} + {commonSampleCode && ( +
+

샘플 코드

+
+                    {commonSampleCode}
+                  
+
+ )} + + {urlInputParams.length === 0 && !commonSampleCode && ( +

요청인자가 등록되지 않았습니다

+ )}
-

- 상세 요청/응답 명세는 추후 제공될 예정입니다 + )} +

+ + {/* 요청인자 */} + {detail.requestParams.length > 0 && ( +
+
+

요청인자

+
+
+ + + + + + + + + + {detail.requestParams.map((param) => ( + + + + + + ))} + +
인자명의미설명
+ {param.paramName} + {param.required && *} + + {param.paramMeaning ?? -} + + {param.paramDescription ?? -} +
+
+
+ )} + + {/* 출력결과 */} + {detail.responseParams.length > 0 && ( +
+
+

출력결과

+
+
+ + + + + + + + + + + {Array.from({ length: Math.ceil(detail.responseParams.length / 2) }, (_, rowIdx) => { + const left = detail.responseParams[rowIdx * 2]; + const right = detail.responseParams[rowIdx * 2 + 1]; + return ( + + + + {right ? ( + <> + + + + ) : ( + <> + + ); + })} + +
변수명의미(단위)변수명의미(단위)
+ {left.paramName} + + {left.paramMeaning ?? -} + + {right.paramName} + + {right.paramMeaning ?? -} + + + + )} +
+
+
+ )} + + {/* 참고자료 */} + {spec?.referenceUrl && ( +
+

참고자료

+ + {spec.referenceUrl} + +
+ )} + + {/* 비고 */} + {spec?.note && ( +
+

비고

+

+ {spec.note}

-
+ )} +
- {/* 사이드: 서비스 정보 */} -
-
-

서비스 정보

- {service ? ( -
-

{service.serviceName}

-

{service.serviceCode}

- {service.description && ( -

{service.description}

- )} -
- - - {service.healthStatus === 'UP' ? '정상' : service.healthStatus === 'DOWN' ? '중단' : '알 수 없음'} - + {/* API 사용 신청 모달 */} + {isModalOpen && ( +
{ if (e.target === e.currentTarget) handleCloseModal(); }} + > +
+ {/* 모달 헤더 */} +
+

API 사용 신청

+ +
+ + {/* 성공 메시지 */} + {modalSuccess ? ( +
+
+

신청이 완료되었습니다

+

관리자 승인 후 API Key가 생성됩니다.

-
) : ( -

서비스 정보를 불러올 수 없습니다

+
+ {/* 에러 메시지 */} + {modalError && ( +
+ {modalError} +
+ )} + + {/* Key Name */} +
+ + setModalKeyName(e.target.value)} + required + placeholder="API Key 이름을 입력하세요" + className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+ + {/* 사용 목적 */} +
+ +