From a5e8e6d51652002c47ae4c951b31e27298c46c6f Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 15 Apr 2026 10:39:08 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(api-hub):=20API=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=ED=95=A8=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EA=B3=B5=ED=86=B5=ED=99=94=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 신청함(BasketContext) + 플로팅 패널 구현 - ApiKeyRequestModal 공통 컴포넌트 추출 (API 선택 트리 포함) - 신청함 도메인별 분류 + 라벨 표시 - 도메인 상세 담기/빼기 버튼 스타일 통일 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/ApiKeyRequestModal.tsx | 425 +++++++++++++++++ frontend/src/hooks/useApiRequestBasket.ts | 10 + frontend/src/layouts/ApiHubLayout.tsx | 96 +++- .../src/pages/apihub/ApiHubApiDetailPage.tsx | 448 ++---------------- .../src/pages/apihub/ApiHubDomainPage.tsx | 23 + frontend/src/pages/apikeys/KeyRequestPage.tsx | 25 +- .../src/store/ApiRequestBasketContext.tsx | 91 ++++ 7 files changed, 718 insertions(+), 400 deletions(-) create mode 100644 frontend/src/components/ApiKeyRequestModal.tsx create mode 100644 frontend/src/hooks/useApiRequestBasket.ts create mode 100644 frontend/src/store/ApiRequestBasketContext.tsx diff --git a/frontend/src/components/ApiKeyRequestModal.tsx b/frontend/src/components/ApiKeyRequestModal.tsx new file mode 100644 index 0000000..6af3f6e --- /dev/null +++ b/frontend/src/components/ApiKeyRequestModal.tsx @@ -0,0 +1,425 @@ +import { useState, useEffect, useMemo } from 'react'; +import { createKeyRequest } from '../services/apiKeyService'; +import { getCatalog } from '../services/apiHubService'; +import type { ServiceCatalog } from '../types/apihub'; + +interface ApiKeyRequestModalProps { + isOpen: boolean; + onClose: () => void; + initialApiIds: number[]; + initialApiNames?: string[]; + onSuccess?: () => void; +} + +const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKeyRequestModalProps) => { + const [keyName, setKeyName] = useState(''); + const [purpose, setPurpose] = useState(''); + const [serviceIp, setServiceIp] = useState(''); + const [servicePurpose, setServicePurpose] = useState(''); + const [dailyEstimate, setDailyEstimate] = useState(''); + const [periodMode, setPeriodMode] = useState<'preset' | 'custom'>('preset'); + const [isPermanent, setIsPermanent] = useState(false); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [selectedApiIds, setSelectedApiIds] = useState>(new Set()); + const [catalog, setCatalog] = useState([]); + const [expandedDomains, setExpandedDomains] = useState>(new Set()); + const [apiSearch, setApiSearch] = useState(''); + + useEffect(() => { + if (isOpen) { + setKeyName(''); + setPurpose(''); + setServiceIp(''); + setServicePurpose(''); + setDailyEstimate(''); + setPeriodMode('preset'); + setIsPermanent(false); + setFromDate(''); + setToDate(''); + setError(null); + setSuccess(false); + setSelectedApiIds(new Set(initialApiIds)); + setApiSearch(''); + // 초기 API의 도메인을 펼침 + if (catalog.length > 0) { + const domains = new Set(); + for (const svc of catalog) { + for (const dg of svc.domains) { + if (dg.apis.some((a) => initialApiIds.includes(a.apiId))) { + domains.add(formatDomainKey(dg.domain)); + } + } + } + setExpandedDomains(domains); + } + if (catalog.length === 0) { + getCatalog().then((res) => { + if (res.data) setCatalog(res.data); + }); + } + } + }, [isOpen]); + + const formatDomainKey = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d); + + // 카탈로그에서 도메인 기준 플랫 그룹 + const domainGroups = useMemo(() => { + const map = new Map(); + for (const svc of catalog) { + for (const dg of svc.domains) { + const key = formatDomainKey(dg.domain); + const existing = map.get(key) ?? []; + for (const a of dg.apis) { + if (!existing.some((e) => e.apiId === a.apiId)) { + if (!apiSearch.trim() || a.apiName.toLowerCase().includes(apiSearch.toLowerCase())) { + existing.push({ apiId: a.apiId, apiName: a.apiName }); + } + } + } + if (existing.length > 0) map.set(key, existing); + } + } + return Array.from(map.entries()); + }, [catalog, apiSearch]); + + const handlePresetPeriod = (months: number) => { + const from = new Date(); + const to = new Date(); + to.setMonth(to.getMonth() + months); + setFromDate(from.toISOString().split('T')[0]); + setToDate(to.toISOString().split('T')[0]); + setPeriodMode('preset'); + setIsPermanent(false); + }; + + const handlePermanent = () => { + setIsPermanent(true); + setFromDate(''); + setToDate(''); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (selectedApiIds.size === 0) { + setError('최소 하나의 API를 선택해주세요.'); + return; + } + if (!isPermanent && (!fromDate || !toDate)) { + setError('사용 기간을 설정해주세요.'); + return; + } + setError(null); + setIsSubmitting(true); + try { + const res = await createKeyRequest({ + keyName, + purpose: purpose || undefined, + requestedApiIds: Array.from(selectedApiIds), + serviceIp: serviceIp || undefined, + servicePurpose: servicePurpose || undefined, + dailyRequestEstimate: dailyEstimate ? Number(dailyEstimate) : undefined, + usageFromDate: isPermanent ? undefined : fromDate, + usageToDate: isPermanent ? undefined : toDate, + }); + if (res.success) { + setSuccess(true); + setTimeout(() => { + onClose(); + onSuccess?.(); + }, 2000); + } else { + setError(res.message || 'API Key 신청에 실패했습니다.'); + } + } catch { + setError('API Key 신청에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + if (!isOpen) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* 헤더 */} +
+

API 사용 신청

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

신청이 완료되었습니다

+

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

+
+
+ ) : ( +
+ {/* 에러 메시지 */} + {error && ( +
+ {error} +
+ )} + + {/* API 선택 (도메인 기반 체크박스 트리) */} +
+
+

+ API 선택 + {selectedApiIds.size > 0 && ( + {selectedApiIds.size}건 선택 + )} +

+ setApiSearch(e.target.value)} + placeholder="API 검색..." + className="bg-white dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg px-2.5 py-1 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:outline-none w-40" + /> +
+
+ {domainGroups.length === 0 ? ( +

검색 결과가 없습니다

+ ) : domainGroups.map(([domain, apis]) => { + const isExpanded = expandedDomains.has(domain); + const allSelected = apis.every((a) => selectedApiIds.has(a.apiId)); + const someSelected = !allSelected && apis.some((a) => selectedApiIds.has(a.apiId)); + return ( +
+
setExpandedDomains((prev) => { const n = new Set(prev); n.has(domain) ? n.delete(domain) : n.add(domain); return n; })} + > + + { if (el) el.indeterminate = someSelected; }} + onChange={() => { + setSelectedApiIds((prev) => { + const n = new Set(prev); + apis.forEach((a) => allSelected ? n.delete(a.apiId) : n.add(a.apiId)); + return n; + }); + }} + onClick={(e) => e.stopPropagation()} + className="rounded" + /> + {domain} + {apis.length} +
+ {isExpanded && apis.map((a) => ( +
setSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })} + > + setSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })} + onClick={(e) => e.stopPropagation()} + className="rounded" + /> + {a.apiName} +
+ ))} +
+ ); + })} +
+
+ + {/* Key Name */} +
+ + setKeyName(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" + /> +
+ + {/* 사용 목적 */} +
+ +