snp-connection-monitoring/frontend/src/pages/admin/ApiEditPage.tsx
HYOJIN 88e25abe14 feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)
- 디자인 시스템 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 최적화
2026-04-17 14:45:27 +09:00

816 lines
31 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;