660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import {
|
|
Globe,
|
|
Key,
|
|
Clock,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Plus,
|
|
Trash2,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
} from "lucide-react";
|
|
|
|
// 타입 import
|
|
import { RestApiSettings as RestApiSettingsType, RestApiSettingsProps } from "@/types/external-call/ExternalCallTypes";
|
|
import {
|
|
HttpMethod,
|
|
AuthenticationType,
|
|
COMMON_HEADER_PRESETS,
|
|
JSON_BODY_TEMPLATES,
|
|
DEFAULT_RETRY_POLICY,
|
|
DEFAULT_TIMEOUT_CONFIG,
|
|
} from "@/types/external-call/RestApiTypes";
|
|
|
|
/**
|
|
* 🔧 REST API 전용 설정 컴포넌트
|
|
*
|
|
* URL, HTTP 메서드, 헤더, 인증, 바디 템플릿 등
|
|
* REST API 호출에 필요한 모든 설정을 관리
|
|
*/
|
|
const RestApiSettings: React.FC<RestApiSettingsProps> = ({ settings, onSettingsChange, readonly = false }) => {
|
|
// 상태 관리
|
|
const [activeTab, setActiveTab] = useState<string>("basic");
|
|
const [showPassword, setShowPassword] = useState<boolean>(false);
|
|
const [isAdvancedOpen, setIsAdvancedOpen] = useState<boolean>(false);
|
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
|
const [newHeaderKey, setNewHeaderKey] = useState<string>("");
|
|
const [newHeaderValue, setNewHeaderValue] = useState<string>("");
|
|
|
|
// URL 변경 핸들러
|
|
const handleUrlChange = useCallback(
|
|
(url: string) => {
|
|
onSettingsChange({
|
|
...settings,
|
|
apiUrl: url,
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// HTTP 메서드 변경 핸들러
|
|
const handleMethodChange = useCallback(
|
|
(method: HttpMethod) => {
|
|
onSettingsChange({
|
|
...settings,
|
|
httpMethod: method,
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 헤더 추가 핸들러
|
|
const handleAddHeader = useCallback(() => {
|
|
if (newHeaderKey && newHeaderValue) {
|
|
onSettingsChange({
|
|
...settings,
|
|
headers: {
|
|
...settings.headers,
|
|
[newHeaderKey]: newHeaderValue,
|
|
},
|
|
});
|
|
setNewHeaderKey("");
|
|
setNewHeaderValue("");
|
|
}
|
|
}, [settings, onSettingsChange, newHeaderKey, newHeaderValue]);
|
|
|
|
// 헤더 삭제 핸들러
|
|
const handleRemoveHeader = useCallback(
|
|
(key: string) => {
|
|
const newHeaders = { ...settings.headers };
|
|
delete newHeaders[key];
|
|
onSettingsChange({
|
|
...settings,
|
|
headers: newHeaders,
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 헤더 프리셋 적용 핸들러
|
|
const handleApplyHeaderPreset = useCallback(
|
|
(presetName: string) => {
|
|
const preset = COMMON_HEADER_PRESETS.find((p) => p.name === presetName);
|
|
if (preset) {
|
|
onSettingsChange({
|
|
...settings,
|
|
headers: {
|
|
...settings.headers,
|
|
...preset.headers,
|
|
},
|
|
});
|
|
}
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 바디 템플릿 변경 핸들러
|
|
const handleBodyTemplateChange = useCallback(
|
|
(template: string) => {
|
|
onSettingsChange({
|
|
...settings,
|
|
bodyTemplate: template,
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 바디 템플릿 프리셋 적용 핸들러
|
|
const handleApplyBodyPreset = useCallback(
|
|
(presetKey: string) => {
|
|
const preset = JSON_BODY_TEMPLATES[presetKey as keyof typeof JSON_BODY_TEMPLATES];
|
|
if (preset) {
|
|
onSettingsChange({
|
|
...settings,
|
|
bodyTemplate: preset.template,
|
|
});
|
|
}
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 인증 설정 변경 핸들러
|
|
const handleAuthChange = useCallback(
|
|
(auth: Partial<AuthenticationType>) => {
|
|
onSettingsChange({
|
|
...settings,
|
|
authentication: {
|
|
...settings.authentication,
|
|
...auth,
|
|
} as AuthenticationType,
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 타임아웃 변경 핸들러 (초 단위를 밀리초로 변환)
|
|
const handleTimeoutChange = useCallback(
|
|
(timeoutInSeconds: number) => {
|
|
onSettingsChange({
|
|
...settings,
|
|
timeout: timeoutInSeconds * 1000, // 초를 밀리초로 변환
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 재시도 횟수 변경 핸들러
|
|
const handleRetryCountChange = useCallback(
|
|
(retryCount: number) => {
|
|
onSettingsChange({
|
|
...settings,
|
|
retryCount,
|
|
});
|
|
},
|
|
[settings, onSettingsChange],
|
|
);
|
|
|
|
// 설정 유효성 검사
|
|
const validateSettings = useCallback(() => {
|
|
const errors: string[] = [];
|
|
|
|
// URL 검증
|
|
if (!settings.apiUrl) {
|
|
errors.push("API URL은 필수입니다.");
|
|
} else if (!settings.apiUrl.startsWith("http")) {
|
|
errors.push("API URL은 http:// 또는 https://로 시작해야 합니다.");
|
|
}
|
|
|
|
// 바디 템플릿 JSON 검증 (POST/PUT/PATCH 메서드인 경우)
|
|
if (["POST", "PUT", "PATCH"].includes(settings.httpMethod) && settings.bodyTemplate) {
|
|
try {
|
|
// 템플릿 변수를 임시 값으로 치환하여 JSON 유효성 검사
|
|
const testTemplate = settings.bodyTemplate.replace(/\{\{[^}]+\}\}/g, '"test_value"');
|
|
JSON.parse(testTemplate);
|
|
} catch {
|
|
errors.push("요청 바디 템플릿이 유효한 JSON 형식이 아닙니다.");
|
|
}
|
|
}
|
|
|
|
// 인증 설정 검증
|
|
if (settings.authentication?.type === "bearer" && !settings.authentication.token) {
|
|
errors.push("Bearer 토큰이 필요합니다.");
|
|
}
|
|
if (
|
|
settings.authentication?.type === "basic" &&
|
|
(!settings.authentication.username || !settings.authentication.password)
|
|
) {
|
|
errors.push("Basic 인증에는 사용자명과 비밀번호가 필요합니다.");
|
|
}
|
|
if (settings.authentication?.type === "api-key" && !settings.authentication.apiKey) {
|
|
errors.push("API 키가 필요합니다.");
|
|
}
|
|
|
|
setValidationErrors(errors);
|
|
return errors.length === 0;
|
|
}, [settings]);
|
|
|
|
// 설정 변경 시 유효성 검사 실행
|
|
useEffect(() => {
|
|
validateSettings();
|
|
}, [validateSettings]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 유효성 검사 오류 표시 */}
|
|
{validationErrors.length > 0 && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
<div className="space-y-1">
|
|
{validationErrors.map((error, index) => (
|
|
<div key={index} className="text-sm">
|
|
• {error}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="basic">기본 설정</TabsTrigger>
|
|
<TabsTrigger value="headers">헤더</TabsTrigger>
|
|
<TabsTrigger value="body">요청 바디</TabsTrigger>
|
|
<TabsTrigger value="auth">인증</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 기본 설정 탭 */}
|
|
<TabsContent value="basic" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Globe className="h-4 w-4" />
|
|
기본 설정
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* API URL */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="apiUrl">API URL *</Label>
|
|
<Input
|
|
id="apiUrl"
|
|
type="url"
|
|
placeholder="https://api.example.com/webhook"
|
|
value={settings.apiUrl}
|
|
onChange={(e) => handleUrlChange(e.target.value)}
|
|
disabled={readonly}
|
|
className={validationErrors.some((e) => e.includes("URL")) ? "border-destructive" : ""}
|
|
/>
|
|
<div className="text-muted-foreground text-xs">호출할 API의 전체 URL을 입력하세요.</div>
|
|
</div>
|
|
|
|
{/* HTTP 메서드 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="httpMethod">HTTP 메서드</Label>
|
|
<Select value={settings.httpMethod} onValueChange={handleMethodChange} disabled={readonly}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="GET">GET</SelectItem>
|
|
<SelectItem value="POST">POST</SelectItem>
|
|
<SelectItem value="PUT">PUT</SelectItem>
|
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
<SelectItem value="PATCH">PATCH</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 고급 설정 (접을 수 있는 섹션) */}
|
|
<Collapsible open={isAdvancedOpen} onOpenChange={setIsAdvancedOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" className="h-auto w-full justify-between p-0">
|
|
<span className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4" />
|
|
고급 설정
|
|
</span>
|
|
{isAdvancedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="mt-4 space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* 타임아웃 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="timeout">타임아웃 (초)</Label>
|
|
<Input
|
|
id="timeout"
|
|
type="number"
|
|
min="1"
|
|
max="300"
|
|
value={Math.round((settings.timeout || DEFAULT_TIMEOUT_CONFIG.request) / 1000)}
|
|
onChange={(e) => handleTimeoutChange(parseInt(e.target.value))}
|
|
disabled={readonly}
|
|
/>
|
|
</div>
|
|
|
|
{/* 재시도 횟수 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="retryCount">재시도 횟수</Label>
|
|
<Input
|
|
id="retryCount"
|
|
type="number"
|
|
min="0"
|
|
max="10"
|
|
value={settings.retryCount || DEFAULT_RETRY_POLICY.maxRetries}
|
|
onChange={(e) => handleRetryCountChange(parseInt(e.target.value))}
|
|
disabled={readonly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 헤더 탭 */}
|
|
<TabsContent value="headers" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">HTTP 헤더</CardTitle>
|
|
<div className="flex gap-2">
|
|
{COMMON_HEADER_PRESETS.map((preset) => (
|
|
<Button
|
|
key={preset.name}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleApplyHeaderPreset(preset.name)}
|
|
disabled={readonly}
|
|
className="text-xs"
|
|
>
|
|
{preset.name}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* 기존 헤더 목록 */}
|
|
<div className="space-y-2">
|
|
{Object.entries(settings.headers).map(([key, value]) => (
|
|
<div key={key} className="bg-muted flex items-center gap-2 rounded p-2">
|
|
<div className="grid flex-1 grid-cols-2 gap-2">
|
|
<Input value={key} disabled className="bg-background" />
|
|
<Input value={value} disabled className="bg-background" />
|
|
</div>
|
|
{!readonly && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemoveHeader(key)}
|
|
className="text-red-500 hover:text-red-700"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 새 헤더 추가 */}
|
|
{!readonly && (
|
|
<div className="space-y-2">
|
|
<Label>새 헤더 추가</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="헤더명 (예: X-API-Key)"
|
|
value={newHeaderKey}
|
|
onChange={(e) => setNewHeaderKey(e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="헤더값"
|
|
value={newHeaderValue}
|
|
onChange={(e) => setNewHeaderValue(e.target.value)}
|
|
/>
|
|
<Button onClick={handleAddHeader} disabled={!newHeaderKey || !newHeaderValue}>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 요청 바디 탭 */}
|
|
<TabsContent value="body" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">요청 바디 템플릿</CardTitle>
|
|
<div className="flex gap-2">
|
|
{Object.entries(JSON_BODY_TEMPLATES).map(([key, template]) => (
|
|
<Button
|
|
key={key}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleApplyBodyPreset(key)}
|
|
disabled={readonly}
|
|
className="text-xs"
|
|
>
|
|
{template.name}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) ? (
|
|
<>
|
|
<Textarea
|
|
placeholder="JSON 템플릿을 입력하세요..."
|
|
value={settings.bodyTemplate}
|
|
onChange={(e) => handleBodyTemplateChange(e.target.value)}
|
|
disabled={readonly}
|
|
className="min-h-[200px] font-mono text-sm"
|
|
/>
|
|
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-sm">
|
|
템플릿 변수를 사용할 수 있습니다: {"{{sourceData}}"}, {"{{timestamp}}"}, {"{{relationshipId}}"} 등
|
|
</AlertDescription>
|
|
</Alert>
|
|
</>
|
|
) : (
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{settings.httpMethod} 메서드는 요청 바디를 사용하지 않습니다.</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 인증 탭 */}
|
|
<TabsContent value="auth" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Key className="h-4 w-4" />
|
|
인증 설정
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* 인증 타입 선택 */}
|
|
<div className="space-y-2">
|
|
<Label>인증 방식</Label>
|
|
<Select
|
|
value={settings.authentication?.type || "none"}
|
|
onValueChange={(type) => handleAuthChange({ type: type as AuthenticationType["type"] })}
|
|
disabled={readonly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">인증 없음</SelectItem>
|
|
<SelectItem value="bearer">Bearer Token</SelectItem>
|
|
<SelectItem value="basic">Basic Authentication</SelectItem>
|
|
<SelectItem value="api-key">API Key</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 인증 타입별 설정 */}
|
|
{settings.authentication?.type === "bearer" && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bearerToken">Bearer Token *</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="bearerToken"
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder="토큰을 입력하세요"
|
|
value={settings.authentication.token || ""}
|
|
onChange={(e) => handleAuthChange({ token: e.target.value })}
|
|
disabled={readonly}
|
|
className="pr-10"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-0 right-0 h-full px-3"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{settings.authentication?.type === "basic" && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="username">사용자명 *</Label>
|
|
<Input
|
|
id="username"
|
|
placeholder="사용자명"
|
|
value={settings.authentication.username || ""}
|
|
onChange={(e) => handleAuthChange({ username: e.target.value })}
|
|
disabled={readonly}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">비밀번호 *</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="password"
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder="비밀번호"
|
|
value={settings.authentication.password || ""}
|
|
onChange={(e) => handleAuthChange({ password: e.target.value })}
|
|
disabled={readonly}
|
|
className="pr-10"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-0 right-0 h-full px-3"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{settings.authentication?.type === "api-key" && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="apiKey">API Key *</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="apiKey"
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder="API 키를 입력하세요"
|
|
value={settings.authentication.apiKey || ""}
|
|
onChange={(e) => handleAuthChange({ apiKey: e.target.value })}
|
|
disabled={readonly}
|
|
className="pr-10"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-0 right-0 h-full px-3"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>위치</Label>
|
|
<Select
|
|
value={settings.authentication.apiKeyLocation || "header"}
|
|
onValueChange={(location) =>
|
|
handleAuthChange({
|
|
apiKeyLocation: location as "header" | "query",
|
|
})
|
|
}
|
|
disabled={readonly}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="header">HTTP 헤더</SelectItem>
|
|
<SelectItem value="query">쿼리 파라미터</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="keyName">키 이름</Label>
|
|
<Input
|
|
id="keyName"
|
|
placeholder={settings.authentication.apiKeyLocation === "query" ? "api_key" : "X-API-Key"}
|
|
value={settings.authentication.apiKeyHeader || settings.authentication.apiKeyQueryParam || ""}
|
|
onChange={(e) => {
|
|
if (settings.authentication?.apiKeyLocation === "query") {
|
|
handleAuthChange({ apiKeyQueryParam: e.target.value });
|
|
} else {
|
|
handleAuthChange({ apiKeyHeader: e.target.value });
|
|
}
|
|
}}
|
|
disabled={readonly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* 설정 상태 표시 */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{validationErrors.length === 0 ? (
|
|
<>
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
<span className="text-sm text-green-600">설정이 완료되었습니다</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AlertCircle className="h-4 w-4 text-orange-500" />
|
|
<span className="text-sm text-orange-600">{validationErrors.length}개의 설정이 필요합니다</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Badge variant={validationErrors.length === 0 ? "default" : "secondary"}>
|
|
{validationErrors.length === 0 ? "완료" : "미완료"}
|
|
</Badge>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RestApiSettings;
|