diff --git a/frontend/src/pages/admin/ApiEditPage.tsx b/frontend/src/pages/admin/ApiEditPage.tsx new file mode 100644 index 0000000..abffb11 --- /dev/null +++ b/frontend/src/pages/admin/ApiEditPage.tsx @@ -0,0 +1,822 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import type { + ServiceInfo, + ApiDetailInfo, + UpdateServiceApiRequest, + SaveApiSpecRequest, + SaveApiParamRequest, +} from '../../types/service'; +import { + getServices, + getApiDetail, + updateServiceApi, + saveApiSpec, + saveApiParams, + deleteServiceApi, + getDomains, +} from '../../services/serviceService'; +import type { ApiDomainInfo } from '../../types/apihub'; + +const METHOD_COLOR: 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', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', +}; + +const INPUT_CLS = + '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 text-sm'; + +const TABLE_INPUT_CLS = + 'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none'; + +const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; + +const ApiEditPage = () => { + const { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{ + serviceId: string; + apiId: string; + }>(); + const navigate = useNavigate(); + + const serviceId = Number(serviceIdStr); + const apiId = Number(apiIdStr); + + // Page state + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const [saving, setSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Meta + const [serviceName, setServiceName] = useState(''); + + // Domains + const [domains, setDomains] = useState([]); + + // Basic info + const [apiMethod, setApiMethod] = useState('GET'); + const [apiPath, setApiPath] = useState(''); + const [apiName, setApiName] = useState(''); + const [apiDomain, setApiDomain] = useState(''); + const [apiSection, setApiSection] = useState(''); + const [apiDescription, setApiDescription] = useState(''); + const [apiIsActive, setApiIsActive] = useState(true); + + // Spec + const [sampleUrl, setSampleUrl] = useState(''); + const [authRequired, setAuthRequired] = useState(false); + const [authType, setAuthType] = useState(''); + const [deprecated, setDeprecated] = useState(false); + const [dataFormat, setDataFormat] = useState(''); + const [referenceUrl, setReferenceUrl] = useState(''); + const [specNote, setSpecNote] = useState(''); + + // Params + const [requestParams, setRequestParams] = useState([]); + const [responseParams, setResponseParams] = useState([]); + + const populateForm = useCallback((data: ApiDetailInfo, services: ServiceInfo[]) => { + const { api, spec, requestParams: rp, responseParams: resp } = data; + + const svc = services.find((s) => s.serviceId === api.serviceId); + if (svc) setServiceName(svc.serviceName); + + setApiMethod(api.apiMethod); + setApiPath(api.apiPath); + setApiName(api.apiName); + setApiDomain(api.apiDomain || ''); + setApiSection(api.apiSection || ''); + setApiDescription(api.description || ''); + setApiIsActive(api.isActive); + + if (spec) { + setSampleUrl(spec.sampleUrl || ''); + setAuthRequired(spec.authRequired); + setAuthType(spec.authType || ''); + setDeprecated(spec.deprecated); + setDataFormat(spec.dataFormat || ''); + setReferenceUrl(spec.referenceUrl || ''); + setSpecNote(spec.note || ''); + } + + setRequestParams( + rp.map((p, i) => ({ + paramType: 'REQUEST' as const, + paramName: p.paramName, + paramMeaning: p.paramMeaning || undefined, + paramDescription: p.paramDescription || undefined, + required: p.required, + defaultValue: p.defaultValue || undefined, + inputType: p.inputType || 'TEXT', + sortOrder: i, + })), + ); + + setResponseParams( + resp.map((p, i) => ({ + paramType: 'RESPONSE' as const, + paramName: p.paramName, + paramMeaning: p.paramMeaning || undefined, + sortOrder: i, + })), + ); + }, []); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [svcRes, detailRes, domainRes] = await Promise.all([ + getServices(), + getApiDetail(serviceId, apiId), + getDomains(), + ]); + + if (domainRes.success && domainRes.data) { + setDomains(domainRes.data); + } + + if (!detailRes.success || !detailRes.data) { + if (detailRes.message?.includes('404') || detailRes.message?.includes('찾을 수 없')) { + setNotFound(true); + } else { + setError(detailRes.message || 'API 정보를 불러오는데 실패했습니다.'); + } + return; + } + + const services = svcRes.success && svcRes.data ? svcRes.data : []; + populateForm(detailRes.data, services); + } catch { + setError('API 정보를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [serviceId, apiId, populateForm]); + + // Param helpers + const addParam = () => { + const newParam: SaveApiParamRequest = { + paramType: 'REQUEST', + paramName: '', + inputType: 'TEXT', + sortOrder: requestParams.length, + }; + setRequestParams((prev) => [...prev, newParam]); + }; + + const updateParam = (index: number, field: string, value: string | boolean | undefined) => { + setRequestParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + }; + + const removeParam = (index: number) => { + setRequestParams((prev) => prev.filter((_, i) => i !== index)); + }; + + const addResponseParam = () => { + const newParam: SaveApiParamRequest = { + paramType: 'RESPONSE', + paramName: '', + sortOrder: responseParams.length, + }; + setResponseParams((prev) => [...prev, newParam]); + }; + + const updateResponseParam = (index: number, field: string, value: string | undefined) => { + setResponseParams((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + }; + + const removeResponseParam = (index: number) => { + setResponseParams((prev) => prev.filter((_, i) => i !== index)); + }; + + const [jsonInput, setJsonInput] = useState(''); + const [jsonError, setJsonError] = useState(null); + const [showJsonInput, setShowJsonInput] = useState(false); + + const parseJsonToParams = () => { + setJsonError(null); + try { + const parsed = JSON.parse(jsonInput); + const params: SaveApiParamRequest[] = []; + + const extract = (obj: unknown, prefix: string) => { + if (obj === null || obj === undefined) return; + if (Array.isArray(obj)) { + params.push({ paramType: 'RESPONSE', paramName: prefix + '[]', sortOrder: params.length }); + if (obj.length > 0 && typeof obj[0] === 'object' && obj[0] !== null) { + extract(obj[0], prefix + '[].'); + } + } else if (typeof obj === 'object') { + for (const key of Object.keys(obj)) { + const fullKey = prefix ? prefix + key : key; + const val = (obj as Record)[key]; + if (val !== null && typeof val === 'object') { + extract(val, fullKey + (Array.isArray(val) ? '' : '.')); + } else { + params.push({ paramType: 'RESPONSE', paramName: fullKey, sortOrder: params.length }); + } + } + } + }; + + extract(parsed, ''); + if (params.length === 0) { + setJsonError('파싱할 키가 없습니다.'); + return; + } + setResponseParams(params); + setJsonInput(''); + setShowJsonInput(false); + } catch { + setJsonError('올바른 JSON 형식이 아닙니다.'); + } + }; + + const handleSave = async () => { + setSaving(true); + setSaveMessage(null); + + try { + const basicReq: UpdateServiceApiRequest = { + apiMethod, + apiPath, + apiName, + apiDomain: apiDomain || undefined, + apiSection: apiSection || undefined, + description: apiDescription || undefined, + isActive: apiIsActive, + }; + + const specReq: SaveApiSpecRequest = { + sampleUrl: sampleUrl || undefined, + authRequired, + authType: authType || undefined, + deprecated, + dataFormat: dataFormat || undefined, + referenceUrl: referenceUrl || undefined, + note: specNote || undefined, + }; + + const allParams: SaveApiParamRequest[] = [ + ...requestParams.map((p, i) => ({ ...p, sortOrder: i })), + ...responseParams.map((p, i) => ({ ...p, sortOrder: i })), + ]; + + const [basicRes, specRes, paramsRes] = await Promise.all([ + updateServiceApi(serviceId, apiId, basicReq), + saveApiSpec(serviceId, apiId, specReq), + saveApiParams(serviceId, apiId, allParams), + ]); + + const basicOk = basicRes.success; + const specOk = specRes.success; + const paramsOk = paramsRes.success; + + if (basicOk && specOk && paramsOk) { + setSaveMessage({ type: 'success', text: '저장되었습니다.' }); + } else { + const errMsg = + (!basicOk ? basicRes.message : null) || + (!specOk ? specRes.message : null) || + (!paramsOk ? paramsRes.message : null) || + '일부 항목 저장에 실패했습니다.'; + setSaveMessage({ type: 'error', text: errMsg }); + } + } catch { + setSaveMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' }); + } finally { + setSaving(false); + setTimeout(() => setSaveMessage(null), 3000); + } + }; + + const handleDelete = async () => { + if (!window.confirm('이 API를 삭제하시겠습니까?')) return; + + try { + const res = await deleteServiceApi(serviceId, apiId); + if (res.success) { + navigate('/admin/apis'); + } else { + setSaveMessage({ type: 'error', text: res.message || 'API 삭제에 실패했습니다.' }); + } + } catch { + setSaveMessage({ type: 'error', text: 'API 삭제 중 오류가 발생했습니다.' }); + } + }; + + // Loading + if (loading) { + return ( +
+
+
+ ); + } + + // Not found + if (notFound) { + return ( +
+

API를 찾을 수 없습니다.

+ + API 목록으로 돌아가기 + +
+ ); + } + + // Error + if (error) { + return ( +
+
+ {error} +
+ + API 목록으로 돌아가기 + +
+ ); + } + + return ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

{apiName}

+
+ + {apiMethod} + + {apiPath} +
+
+
+ + +
+
+ + {/* Save message */} + {saveMessage && ( +
+ {saveMessage.text} +
+ )} + + {/* Sections */} +
+ {/* Section 1: 기본 정보 */} +
+

기본 정보

+
+
+ + +
+
+ + setApiPath(e.target.value)} + placeholder="/api/v1/example" + className={`${INPUT_CLS} font-mono`} + /> +
+
+ + setApiName(e.target.value)} + placeholder="API 이름" + className={INPUT_CLS} + /> +
+
+ + +
+
+ + setApiSection(e.target.value)} + placeholder="섹션 (선택)" + className={INPUT_CLS} + /> +
+
+ setApiIsActive(e.target.checked)} + className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500" + /> + +
+
+
+ +