generated from gc/template-java-maven
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info) - PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용 - SERVICE_BADGE_VARIANTS 공통 상수 추출 - 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영 - 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs) - 타이틀 아이콘 전체 페이지 통일 - 카드 테두리 디자인 통일 (border + rounded-xl) - FHD 1920x1080 최적화
816 lines
31 KiB
TypeScript
816 lines
31 KiB
TypeScript
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';
|
||
import Badge from '../../components/ui/Badge';
|
||
import Button from '../../components/ui/Button';
|
||
|
||
const METHOD_CLASS: Record<string, string> = {
|
||
GET: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||
POST: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||
PUT: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||
DELETE: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||
};
|
||
|
||
const INPUT_CLS =
|
||
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none text-sm';
|
||
|
||
const TABLE_INPUT_CLS =
|
||
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none';
|
||
|
||
const LABEL_CLS = 'block text-sm font-medium text-[var(--color-text-primary)] 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<string | null>(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<ApiDomainInfo[]>([]);
|
||
|
||
// 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<SaveApiParamRequest[]>([]);
|
||
const [responseParams, setResponseParams] = useState<SaveApiParamRequest[]>([]);
|
||
|
||
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<string | null>(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<string, unknown>)[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 (
|
||
<div className="flex items-center justify-center py-20">
|
||
<div className="w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Not found
|
||
if (notFound) {
|
||
return (
|
||
<div className="max-w-5xl mx-auto text-center py-20">
|
||
<p className="text-[var(--color-text-secondary)] text-lg">API를 찾을 수 없습니다.</p>
|
||
<Link
|
||
to="/admin/apis"
|
||
className="mt-4 inline-block text-[var(--color-primary)] hover:underline text-sm"
|
||
>
|
||
API 목록으로 돌아가기
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Error
|
||
if (error) {
|
||
return (
|
||
<div className="max-w-5xl mx-auto py-10">
|
||
<div className="p-4 bg-red-50 text-red-700 rounded-lg text-sm">
|
||
{error}
|
||
</div>
|
||
<Link
|
||
to="/admin/apis"
|
||
className="mt-4 inline-block text-[var(--color-primary)] hover:underline text-sm"
|
||
>
|
||
API 목록으로 돌아가기
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-5xl mx-auto">
|
||
{/* Breadcrumb */}
|
||
<nav className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] mb-4">
|
||
<Link
|
||
to="/admin/apis"
|
||
className="hover:text-[var(--color-primary)] transition-colors"
|
||
>
|
||
API 관리
|
||
</Link>
|
||
<span>/</span>
|
||
<span className="text-[var(--color-text-primary)]">{serviceName || `서비스 #${serviceId}`}</span>
|
||
<span>/</span>
|
||
<span className="text-[var(--color-text-primary)] font-medium">{apiName || `API #${apiId}`}</span>
|
||
</nav>
|
||
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-1">
|
||
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
||
<path d="M4 6h16M4 12h16M4 18h10" />
|
||
</svg>
|
||
</div>
|
||
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">{apiName}</h1>
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-1.5">
|
||
<Badge className={METHOD_CLASS[apiMethod] ?? 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)]'}>
|
||
{apiMethod}
|
||
</Badge>
|
||
<span className="text-sm text-[var(--color-text-tertiary)]">{apiPath}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Button onClick={handleDelete} variant="danger" size="sm">
|
||
삭제
|
||
</Button>
|
||
<Button onClick={handleSave} disabled={saving} variant="primary" size="sm">
|
||
{saving ? '저장 중...' : '저장'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Save message */}
|
||
{saveMessage && (
|
||
<div
|
||
className={`mb-4 p-3 rounded-lg text-sm ${
|
||
saveMessage.type === 'success'
|
||
? 'bg-green-50 text-green-700'
|
||
: 'bg-red-50 text-red-700'
|
||
}`}
|
||
>
|
||
{saveMessage.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Sections */}
|
||
<div className="space-y-6 mt-6">
|
||
{/* Section 1: 기본 정보 */}
|
||
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<label className={LABEL_CLS}>Method</label>
|
||
<select
|
||
value={apiMethod}
|
||
onChange={(e) => setApiMethod(e.target.value)}
|
||
className={INPUT_CLS}
|
||
>
|
||
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
|
||
<option key={m} value={m}>{m}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>API Path</label>
|
||
<input
|
||
type="text"
|
||
value={apiPath}
|
||
onChange={(e) => setApiPath(e.target.value)}
|
||
placeholder="/api/v1/example"
|
||
className={INPUT_CLS}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>API명</label>
|
||
<input
|
||
type="text"
|
||
value={apiName}
|
||
onChange={(e) => setApiName(e.target.value)}
|
||
placeholder="API 이름"
|
||
className={INPUT_CLS}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>Domain</label>
|
||
<select
|
||
value={apiDomain}
|
||
onChange={(e) => setApiDomain(e.target.value)}
|
||
className={INPUT_CLS}
|
||
>
|
||
<option value="">선택 안함</option>
|
||
{domains.map((d) => (
|
||
<option key={d.domainId} value={d.domainName}>{d.domainName}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>Section</label>
|
||
<input
|
||
type="text"
|
||
value={apiSection}
|
||
onChange={(e) => setApiSection(e.target.value)}
|
||
placeholder="섹션 (선택)"
|
||
className={INPUT_CLS}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-6">
|
||
<input
|
||
type="checkbox"
|
||
id="apiIsActive"
|
||
checked={apiIsActive}
|
||
onChange={(e) => setApiIsActive(e.target.checked)}
|
||
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||
/>
|
||
<label htmlFor="apiIsActive" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||
Active
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>설명</label>
|
||
<textarea
|
||
value={apiDescription}
|
||
onChange={(e) => setApiDescription(e.target.value)}
|
||
rows={3}
|
||
placeholder="API 설명 (선택)"
|
||
className={`${INPUT_CLS} resize-none`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section 2: API 명세 */}
|
||
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">API 명세</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className={LABEL_CLS}>샘플 URL</label>
|
||
<input
|
||
type="text"
|
||
value={sampleUrl}
|
||
onChange={(e) => setSampleUrl(e.target.value)}
|
||
placeholder="https://example.com/api/v1/..."
|
||
className={INPUT_CLS}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="authRequired"
|
||
checked={authRequired}
|
||
onChange={(e) => setAuthRequired(e.target.checked)}
|
||
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||
/>
|
||
<label htmlFor="authRequired" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||
인증 필요
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>인증 방식</label>
|
||
<select
|
||
value={authType}
|
||
onChange={(e) => setAuthType(e.target.value)}
|
||
className={INPUT_CLS}
|
||
>
|
||
<option value="">없음</option>
|
||
<option value="API_KEY">API_KEY</option>
|
||
<option value="JWT">JWT</option>
|
||
<option value="NONE">NONE</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="deprecated"
|
||
checked={deprecated}
|
||
onChange={(e) => setDeprecated(e.target.checked)}
|
||
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||
/>
|
||
<label htmlFor="deprecated" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||
Deprecated
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>데이터 포맷</label>
|
||
<input
|
||
type="text"
|
||
value={dataFormat}
|
||
onChange={(e) => setDataFormat(e.target.value)}
|
||
placeholder="JSON, XML 등"
|
||
className={INPUT_CLS}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>참고자료 URL</label>
|
||
<input
|
||
type="text"
|
||
value={referenceUrl}
|
||
onChange={(e) => setReferenceUrl(e.target.value)}
|
||
placeholder="https://..."
|
||
className={INPUT_CLS}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL_CLS}>비고</label>
|
||
<textarea
|
||
value={specNote}
|
||
onChange={(e) => setSpecNote(e.target.value)}
|
||
rows={2}
|
||
placeholder="추가 참고사항 (선택)"
|
||
className={`${INPUT_CLS} resize-none`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section 3: 요청인자 */}
|
||
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
||
<Button type="button" onClick={() => addParam()} variant="secondary" size="sm">
|
||
행 추가
|
||
</Button>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="h-8 border-b border-[var(--color-border)]">
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-10">#</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">인자명</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">의미</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">설명</th>
|
||
<th className="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-14">필수</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-28">입력유형</th>
|
||
<th className="px-3 py-2 w-10" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[var(--color-border)]">
|
||
{requestParams.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={7}
|
||
className="px-2 py-6 text-center text-[var(--color-text-tertiary)] text-sm"
|
||
>
|
||
등록된 요청인자가 없습니다
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
requestParams.map((param, idx) => (
|
||
<tr key={idx} className="h-7">
|
||
<td className="px-3 py-1 text-[var(--color-text-secondary)] text-center text-xs">
|
||
{idx + 1}
|
||
</td>
|
||
<td className="px-3 py-1">
|
||
<input
|
||
type="text"
|
||
value={param.paramName}
|
||
onChange={(e) => updateParam(idx, 'paramName', e.target.value)}
|
||
placeholder="paramName"
|
||
className={TABLE_INPUT_CLS}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1">
|
||
<input
|
||
type="text"
|
||
value={param.paramMeaning ?? ''}
|
||
onChange={(e) =>
|
||
updateParam(idx, 'paramMeaning', e.target.value || undefined)
|
||
}
|
||
placeholder="의미"
|
||
className={TABLE_INPUT_CLS}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1">
|
||
<input
|
||
type="text"
|
||
value={param.paramDescription ?? ''}
|
||
onChange={(e) =>
|
||
updateParam(idx, 'paramDescription', e.target.value || undefined)
|
||
}
|
||
placeholder="설명"
|
||
className={TABLE_INPUT_CLS}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={param.required ?? false}
|
||
onChange={(e) => updateParam(idx, 'required', e.target.checked)}
|
||
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1">
|
||
<select
|
||
value={param.inputType ?? 'TEXT'}
|
||
onChange={(e) => updateParam(idx, 'inputType', e.target.value)}
|
||
className={TABLE_INPUT_CLS}
|
||
>
|
||
<option value="TEXT">TEXT</option>
|
||
<option value="NUMBER">NUMBER</option>
|
||
<option value="DATE">DATE</option>
|
||
<option value="DATETIME">DATETIME</option>
|
||
</select>
|
||
</td>
|
||
<td className="px-3 py-1 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => removeParam(idx)}
|
||
className="text-red-500 hover:text-red-700 font-bold text-base leading-none"
|
||
title="삭제"
|
||
>
|
||
×
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section 4: 출력결과 */}
|
||
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
onClick={() => setShowJsonInput((v) => !v)}
|
||
variant="accent"
|
||
size="sm"
|
||
>
|
||
{showJsonInput ? 'JSON 닫기' : 'JSON 파싱'}
|
||
</Button>
|
||
<Button type="button" onClick={addResponseParam} variant="secondary" size="sm">
|
||
행 추가
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{showJsonInput && (
|
||
<div className="mb-4 p-4 bg-[var(--color-bg-base)] rounded-lg border border-[var(--color-border)]">
|
||
<p className="text-xs text-[var(--color-text-secondary)] mb-2">
|
||
JSON 응답 예시를 붙여넣으면 키를 자동으로 추출합니다. (기존 목록을 대체합니다)
|
||
</p>
|
||
<textarea
|
||
value={jsonInput}
|
||
onChange={(e) => { setJsonInput(e.target.value); setJsonError(null); }}
|
||
rows={6}
|
||
placeholder={'{\n "result": "success",\n "data": {\n "id": 1,\n "name": "example"\n }\n}'}
|
||
className={`${INPUT_CLS} resize-none`}
|
||
/>
|
||
{jsonError && (
|
||
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
onClick={parseJsonToParams}
|
||
disabled={!jsonInput.trim()}
|
||
variant="accent"
|
||
size="sm"
|
||
className="mt-2"
|
||
>
|
||
파싱
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="h-8 border-b border-[var(--color-border)]">
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)] w-10">#</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">변수명</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">의미(단위)</th>
|
||
<th className="px-3 py-2 w-10" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[var(--color-border)]">
|
||
{responseParams.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={4}
|
||
className="px-2 py-6 text-center text-[var(--color-text-tertiary)] text-sm"
|
||
>
|
||
등록된 출력결과가 없습니다
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
responseParams.map((param, idx) => (
|
||
<tr key={idx} className="h-7">
|
||
<td className="px-3 py-1 text-[var(--color-text-secondary)] text-center text-xs">
|
||
{idx + 1}
|
||
</td>
|
||
<td className="px-3 py-1">
|
||
<input
|
||
type="text"
|
||
value={param.paramName}
|
||
onChange={(e) => updateResponseParam(idx, 'paramName', e.target.value)}
|
||
placeholder="variableName"
|
||
className={TABLE_INPUT_CLS}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1">
|
||
<input
|
||
type="text"
|
||
value={param.paramMeaning ?? ''}
|
||
onChange={(e) =>
|
||
updateResponseParam(idx, 'paramMeaning', e.target.value || undefined)
|
||
}
|
||
placeholder="의미(단위)"
|
||
className={TABLE_INPUT_CLS}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-1 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => removeResponseParam(idx)}
|
||
className="text-red-500 hover:text-red-700 font-bold text-base leading-none"
|
||
title="삭제"
|
||
>
|
||
×
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ApiEditPage;
|