ERP-node/frontend/components/admin/RestApiConnectionModal.tsx

605 lines
22 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { X, Save, TestTube, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import {
ExternalRestApiConnectionAPI,
ExternalRestApiConnection,
AuthType,
RestApiTestResult,
RestApiTestRequest,
} from "@/lib/api/externalRestApiConnection";
import { HeadersManager } from "./HeadersManager";
import { AuthenticationConfig } from "./AuthenticationConfig";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface RestApiConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
connection?: ExternalRestApiConnection;
}
export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: RestApiConnectionModalProps) {
const { toast } = useToast();
// 폼 상태
const [connectionName, setConnectionName] = useState("");
const [description, setDescription] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [endpointPath, setEndpointPath] = useState("");
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
const [defaultMethod, setDefaultMethod] = useState("GET");
const [defaultBody, setDefaultBody] = useState("");
const [authType, setAuthType] = useState<AuthType>("none");
const [authConfig, setAuthConfig] = useState<any>({});
const [timeout, setTimeout] = useState(30000);
const [retryCount, setRetryCount] = useState(0);
const [retryDelay, setRetryDelay] = useState(1000);
const [isActive, setIsActive] = useState(true);
const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정
// UI 상태
const [showAdvanced, setShowAdvanced] = useState(false);
const [testEndpoint, setTestEndpoint] = useState("");
const [testMethod, setTestMethod] = useState("GET");
const [testBody, setTestBody] = useState("");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
const [saving, setSaving] = useState(false);
// 기존 연결 데이터 로드
useEffect(() => {
if (connection) {
setConnectionName(connection.connection_name);
setDescription(connection.description || "");
setBaseUrl(connection.base_url);
setEndpointPath(connection.endpoint_path || "");
setDefaultHeaders(connection.default_headers || {});
setDefaultMethod(connection.default_method || "GET");
setDefaultBody(connection.default_body || "");
setAuthType(connection.auth_type);
setAuthConfig(connection.auth_config || {});
setTimeout(connection.timeout || 30000);
setRetryCount(connection.retry_count || 0);
setRetryDelay(connection.retry_delay || 1000);
setIsActive(connection.is_active === "Y");
setSaveToHistory(connection.save_to_history === "Y");
// 테스트 초기값 설정
setTestEndpoint("");
setTestMethod(connection.default_method || "GET");
setTestBody(connection.default_body || "");
} else {
// 초기화
setConnectionName("");
setDescription("");
setBaseUrl("");
setEndpointPath("");
setDefaultHeaders({ "Content-Type": "application/json" });
setDefaultMethod("GET");
setDefaultBody("");
setAuthType("none");
setAuthConfig({});
setTimeout(30000);
setRetryCount(0);
setRetryDelay(1000);
setIsActive(true);
setSaveToHistory(false);
// 테스트 초기값 설정
setTestEndpoint("");
setTestMethod("GET");
setTestBody("");
}
setTestResult(null);
setTestRequestUrl("");
}, [connection, isOpen]);
// 연결 테스트
const handleTest = async () => {
// 유효성 검증
if (!baseUrl.trim()) {
toast({
title: "입력 오류",
description: "기본 URL을 입력해주세요.",
variant: "destructive",
});
return;
}
setTesting(true);
setTestResult(null);
// 사용자가 테스트하려는 실제 외부 API URL 설정
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl;
setTestRequestUrl(fullUrl);
try {
const testRequest: RestApiTestRequest = {
base_url: baseUrl,
endpoint: testEndpoint || undefined,
method: testMethod as any,
headers: defaultHeaders,
body: testBody ? JSON.parse(testBody) : undefined,
auth_type: authType,
auth_config: authConfig,
timeout,
};
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
setTestResult(result);
if (result.success) {
toast({
title: "연결 성공",
description: `응답 시간: ${result.response_time}ms`,
});
} else {
toast({
title: "연결 실패",
description: result.message,
variant: "destructive",
});
}
} catch (error) {
toast({
title: "테스트 오류",
description: error instanceof Error ? error.message : "알 수 없는 오류",
variant: "destructive",
});
} finally {
setTesting(false);
}
};
// 저장
const handleSave = async () => {
// 유효성 검증
if (!connectionName.trim()) {
toast({
title: "입력 오류",
description: "연결명을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!baseUrl.trim()) {
toast({
title: "입력 오류",
description: "기본 URL을 입력해주세요.",
variant: "destructive",
});
return;
}
// URL 형식 검증
try {
new URL(baseUrl);
} catch {
toast({
title: "입력 오류",
description: "올바른 URL 형식이 아닙니다.",
variant: "destructive",
});
return;
}
// JSON 유효성 검증
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
try {
JSON.parse(defaultBody);
} catch {
toast({
title: "입력 오류",
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
variant: "destructive",
});
return;
}
}
setSaving(true);
try {
const data: ExternalRestApiConnection = {
connection_name: connectionName,
description: description || undefined,
base_url: baseUrl,
endpoint_path: endpointPath || undefined,
default_headers: defaultHeaders,
default_method: defaultMethod,
default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트
auth_type: authType,
auth_config: authType === "none" ? undefined : authConfig,
timeout,
retry_count: retryCount,
retry_delay: retryDelay,
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
is_active: isActive ? "Y" : "N",
save_to_history: saveToHistory ? "Y" : "N",
};
console.log("저장하려는 데이터:", {
connection_name: connectionName,
default_method: defaultMethod,
endpoint_path: endpointPath,
base_url: baseUrl,
});
if (connection?.id) {
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
toast({
title: "수정 완료",
description: "연결이 수정되었습니다.",
});
} else {
await ExternalRestApiConnectionAPI.createConnection(data);
toast({
title: "생성 완료",
description: "연결이 생성되었습니다.",
});
}
onSave();
onClose();
} catch (error) {
toast({
title: "저장 실패",
description: error instanceof Error ? error.message : "알 수 없는 오류",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
<DialogHeader>
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 기본 정보 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label htmlFor="connection-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="connection-name"
value={connectionName}
onChange={(e) => setConnectionName(e.target.value)}
placeholder="예: 날씨 API"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="연결에 대한 설명을 입력하세요"
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="base-url">
URL <span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<Select
value={defaultMethod}
onValueChange={(val) => {
setDefaultMethod(val);
setTestMethod(val); // 테스트 Method도 동기화
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Method" />
</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 className="flex-1">
<Input
id="base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
(: https://apihub.kma.go.kr)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint-path"> </Label>
<Input
id="endpoint-path"
value={endpointPath}
onChange={(e) => setEndpointPath(e.target.value)}
placeholder="/api/typ01/url/wrn_now_data.php"
/>
<p className="text-muted-foreground text-xs">
API ()
</p>
</div>
{/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
{(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
<div className="space-y-2">
<Label htmlFor="default-body"> Request Body (JSON)</Label>
<Textarea
id="default-body"
value={defaultBody}
onChange={(e) => setDefaultBody(e.target.value)}
placeholder='{"key": "value"}'
className="font-mono text-xs"
rows={5}
/>
</div>
)}
<div className="flex items-center space-x-2">
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is-active" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<Switch id="save-to-history" checked={saveToHistory} onCheckedChange={setSaveToHistory} />
<Label htmlFor="save-to-history" className="cursor-pointer">
</Label>
<span className="text-xs text-muted-foreground">
( API vehicle_location_history에 )
</span>
</div>
</div>
{/* 헤더 관리 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<HeadersManager headers={defaultHeaders} onChange={setDefaultHeaders} />
</div>
{/* 인증 설정 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<AuthenticationConfig
authType={authType}
authConfig={authConfig}
onAuthTypeChange={setAuthType}
onAuthConfigChange={setAuthConfig}
/>
</div>
{/* 고급 설정 */}
<div className="space-y-4">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="hover:text-primary flex items-center space-x-2 text-sm font-semibold transition-colors"
>
<span> </span>
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{showAdvanced && (
<div className="bg-muted space-y-4 rounded-md border p-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="timeout"> (ms)</Label>
<Input
id="timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(parseInt(e.target.value) || 30000)}
min={1000}
max={120000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-count"> </Label>
<Input
id="retry-count"
type="number"
value={retryCount}
onChange={(e) => setRetryCount(parseInt(e.target.value) || 0)}
min={0}
max={5}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay"> (ms)</Label>
<Input
id="retry-delay"
type="number"
value={retryDelay}
onChange={(e) => setRetryDelay(parseInt(e.target.value) || 1000)}
min={100}
max={10000}
/>
</div>
</div>
</div>
)}
</div>
{/* 연결 테스트 */}
<div className="space-y-4">
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label htmlFor="test-endpoint"> </Label>
<div className="flex gap-2 mb-2">
<Select value={testMethod} onValueChange={setTestMethod}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Method" />
</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 className="flex-1">
<Input
id="test-endpoint"
value={testEndpoint}
onChange={(e) => setTestEndpoint(e.target.value)}
placeholder="엔드포인트 (예: /users/1)"
/>
</div>
</div>
{(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
<div className="mt-2">
<Label htmlFor="test-body" className="text-xs text-muted-foreground mb-1 block">
Test Request Body (JSON)
</Label>
<Textarea
id="test-body"
value={testBody}
onChange={(e) => setTestBody(e.target.value)}
placeholder='{"test": "data"}'
className="font-mono text-xs"
rows={3}
/>
</div>
)}
</div>
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
<TestTube className="mr-2 h-4 w-4" />
{testing ? "테스트 중..." : "연결 테스트"}
</Button>
{/* 테스트 요청 정보 표시 */}
{testRequestUrl && (
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{testMethod}</Badge>
<code className="text-foreground text-xs break-all">{testRequestUrl}</code>
</div>
</div>
{testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">Request Body</div>
<pre className="bg-muted p-2 rounded text-xs overflow-auto max-h-[100px]">
{testBody}
</pre>
</div>
)}
{Object.keys(defaultHeaders).length > 0 && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<div className="space-y-1">
{Object.entries(defaultHeaders).map(([key, value]) => (
<code key={key} className="text-foreground block text-xs">
{key}: {value}
</code>
))}
</div>
</div>
)}
{authType !== "none" && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<code className="text-foreground block text-xs">
{authType === "api-key" && "API Key"}
{authType === "bearer" && "Bearer Token"}
{authType === "basic" && "Basic Auth"}
{authType === "oauth2" && "OAuth 2.0"}
</code>
</div>
)}
</div>
)}
{testResult && (
<div
className={`rounded-md border p-4 ${
testResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"
}`}
>
<div className="mb-2 flex items-center justify-between">
<Badge variant={testResult.success ? "default" : "destructive"}>
{testResult.success ? "성공" : "실패"}
</Badge>
{testResult.response_time && (
<span className="text-sm text-gray-600"> : {testResult.response_time}ms</span>
)}
</div>
<p className="text-sm">{testResult.message}</p>
{testResult.status_code && (
<p className="mt-1 text-xs text-gray-500"> : {testResult.status_code}</p>
)}
{testResult.error_details && <p className="mt-2 text-xs text-red-600">{testResult.error_details}</p>}
</div>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
<X className="mr-2 h-4 w-4" />
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? "저장 중..." : connection ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}