588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { toast } from "sonner";
|
|
import {
|
|
ExternalCallConfigAPI,
|
|
ExternalCallConfig,
|
|
CALL_TYPE_OPTIONS,
|
|
API_TYPE_OPTIONS,
|
|
ACTIVE_STATUS_OPTIONS,
|
|
} from "@/lib/api/externalCallConfig";
|
|
|
|
interface ExternalCallConfigModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
editingConfig?: ExternalCallConfig | null;
|
|
}
|
|
|
|
export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig }: ExternalCallConfigModalProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [formData, setFormData] = useState<Partial<ExternalCallConfig>>({
|
|
config_name: "",
|
|
call_type: "rest-api",
|
|
api_type: "discord",
|
|
description: "",
|
|
is_active: "Y",
|
|
config_data: {},
|
|
});
|
|
|
|
// Discord 설정 상태
|
|
const [discordSettings, setDiscordSettings] = useState({
|
|
webhookUrl: "",
|
|
username: "",
|
|
avatarUrl: "",
|
|
});
|
|
|
|
// Slack 설정 상태
|
|
const [slackSettings, setSlackSettings] = useState({
|
|
webhookUrl: "",
|
|
channel: "",
|
|
username: "",
|
|
});
|
|
|
|
// 카카오톡 설정 상태
|
|
const [kakaoSettings, setKakaoSettings] = useState({
|
|
accessToken: "",
|
|
templateId: "",
|
|
});
|
|
|
|
// 일반 API 설정 상태
|
|
const [genericSettings, setGenericSettings] = useState({
|
|
url: "",
|
|
method: "POST",
|
|
headers: "{}",
|
|
timeout: "30000",
|
|
});
|
|
|
|
// 편집 모드일 때 기존 데이터 로드
|
|
useEffect(() => {
|
|
if (isOpen && editingConfig) {
|
|
setFormData({
|
|
config_name: editingConfig.config_name,
|
|
call_type: editingConfig.call_type,
|
|
api_type: editingConfig.api_type,
|
|
description: editingConfig.description || "",
|
|
is_active: editingConfig.is_active || "Y",
|
|
config_data: editingConfig.config_data,
|
|
});
|
|
|
|
// API 타입별 설정 데이터 로드
|
|
const configData = editingConfig.config_data as any;
|
|
|
|
if (editingConfig.api_type === "discord") {
|
|
setDiscordSettings({
|
|
webhookUrl: configData.webhookUrl || "",
|
|
username: configData.username || "",
|
|
avatarUrl: configData.avatarUrl || "",
|
|
});
|
|
} else if (editingConfig.api_type === "slack") {
|
|
setSlackSettings({
|
|
webhookUrl: configData.webhookUrl || "",
|
|
channel: configData.channel || "",
|
|
username: configData.username || "",
|
|
});
|
|
} else if (editingConfig.api_type === "kakao-talk") {
|
|
setKakaoSettings({
|
|
accessToken: configData.accessToken || "",
|
|
templateId: configData.templateId || "",
|
|
});
|
|
} else if (editingConfig.api_type === "generic") {
|
|
setGenericSettings({
|
|
url: configData.url || "",
|
|
method: configData.method || "POST",
|
|
headers: JSON.stringify(configData.headers || {}, null, 2),
|
|
timeout: String(configData.timeout || 30000),
|
|
});
|
|
}
|
|
} else if (isOpen && !editingConfig) {
|
|
// 새 설정 추가 시 초기화
|
|
setFormData({
|
|
config_name: "",
|
|
call_type: "rest-api",
|
|
api_type: "discord",
|
|
description: "",
|
|
is_active: "Y",
|
|
config_data: {},
|
|
});
|
|
setDiscordSettings({ webhookUrl: "", username: "", avatarUrl: "" });
|
|
setSlackSettings({ webhookUrl: "", channel: "", username: "" });
|
|
setKakaoSettings({ accessToken: "", templateId: "" });
|
|
setGenericSettings({ url: "", method: "POST", headers: "{}", timeout: "30000" });
|
|
}
|
|
}, [isOpen, editingConfig]);
|
|
|
|
// 호출 타입 변경 시 API 타입 초기화
|
|
const handleCallTypeChange = (callType: string) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
call_type: callType,
|
|
api_type: callType === "rest-api" ? "discord" : undefined,
|
|
}));
|
|
};
|
|
|
|
// config_data 생성
|
|
const generateConfigData = () => {
|
|
const { api_type } = formData;
|
|
|
|
switch (api_type) {
|
|
case "discord":
|
|
return {
|
|
webhookUrl: discordSettings.webhookUrl,
|
|
username: discordSettings.username || "ERP 시스템",
|
|
avatarUrl: discordSettings.avatarUrl || null,
|
|
};
|
|
|
|
case "slack":
|
|
return {
|
|
webhookUrl: slackSettings.webhookUrl,
|
|
channel: slackSettings.channel,
|
|
username: slackSettings.username || "ERP Bot",
|
|
};
|
|
|
|
case "kakao-talk":
|
|
return {
|
|
accessToken: kakaoSettings.accessToken,
|
|
templateId: kakaoSettings.templateId,
|
|
};
|
|
|
|
case "generic":
|
|
try {
|
|
return {
|
|
url: genericSettings.url,
|
|
method: genericSettings.method,
|
|
headers: JSON.parse(genericSettings.headers),
|
|
timeout: parseInt(genericSettings.timeout),
|
|
};
|
|
} catch (error) {
|
|
throw new Error("헤더 JSON 형식이 올바르지 않습니다.");
|
|
}
|
|
|
|
default:
|
|
return {};
|
|
}
|
|
};
|
|
|
|
// 폼 검증
|
|
const validateForm = () => {
|
|
if (!formData.config_name?.trim()) {
|
|
toast.error("설정 이름을 입력해주세요.");
|
|
return false;
|
|
}
|
|
|
|
if (!formData.call_type) {
|
|
toast.error("호출 타입을 선택해주세요.");
|
|
return false;
|
|
}
|
|
|
|
// REST API인 경우 API 타입별 검증
|
|
if (formData.call_type === "rest-api") {
|
|
switch (formData.api_type) {
|
|
case "discord":
|
|
if (!discordSettings.webhookUrl.trim()) {
|
|
toast.error("Discord 웹훅 URL을 입력해주세요.");
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case "slack":
|
|
if (!slackSettings.webhookUrl.trim()) {
|
|
toast.error("Slack 웹훅 URL을 입력해주세요.");
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case "kakao-talk":
|
|
if (!kakaoSettings.accessToken.trim()) {
|
|
toast.error("카카오톡 액세스 토큰을 입력해주세요.");
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case "generic":
|
|
if (!genericSettings.url.trim()) {
|
|
toast.error("API URL을 입력해주세요.");
|
|
return false;
|
|
}
|
|
try {
|
|
JSON.parse(genericSettings.headers);
|
|
} catch {
|
|
toast.error("헤더 JSON 형식이 올바르지 않습니다.");
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
// 저장 처리
|
|
const handleSave = async () => {
|
|
if (!validateForm()) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
const configData = generateConfigData();
|
|
const saveData = {
|
|
...formData,
|
|
config_data: configData,
|
|
};
|
|
|
|
let response;
|
|
if (editingConfig?.id) {
|
|
response = await ExternalCallConfigAPI.updateConfig(editingConfig.id, saveData);
|
|
} else {
|
|
response = await ExternalCallConfigAPI.createConfig(saveData as any);
|
|
}
|
|
|
|
if (response.success) {
|
|
toast.success(editingConfig ? "외부 호출 설정이 수정되었습니다." : "외부 호출 설정이 생성되었습니다.");
|
|
onSave();
|
|
} else {
|
|
toast.error(response.message || "저장 실패");
|
|
}
|
|
} catch (error) {
|
|
console.error("외부 호출 설정 저장 오류:", error);
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[60vh] space-y-4 overflow-y-auto sm:space-y-6">
|
|
{/* 기본 정보 */}
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label htmlFor="config_name" className="text-xs sm:text-sm">
|
|
설정 이름 *
|
|
</Label>
|
|
<Input
|
|
id="config_name"
|
|
value={formData.config_name}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, config_name: e.target.value }))}
|
|
placeholder="예: 개발팀 Discord"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
설명
|
|
</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
|
placeholder="이 외부 호출 설정에 대한 설명을 입력하세요."
|
|
rows={2}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
|
<div>
|
|
<Label htmlFor="call_type" className="text-xs sm:text-sm">
|
|
호출 타입 *
|
|
</Label>
|
|
<Select value={formData.call_type} onValueChange={handleCallTypeChange}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CALL_TYPE_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="is_active" className="text-xs sm:text-sm">
|
|
상태
|
|
</Label>
|
|
<Select
|
|
value={formData.is_active}
|
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, is_active: value }))}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* REST API 설정 */}
|
|
{formData.call_type === "rest-api" && (
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label htmlFor="api_type" className="text-xs sm:text-sm">
|
|
API 타입 *
|
|
</Label>
|
|
<Select
|
|
value={formData.api_type}
|
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, api_type: value }))}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{API_TYPE_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Discord 설정 */}
|
|
{formData.api_type === "discord" && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
|
<h4 className="text-xs font-semibold sm:text-sm">Discord 설정</h4>
|
|
<div>
|
|
<Label htmlFor="discord_webhook" className="text-xs sm:text-sm">
|
|
웹훅 URL *
|
|
</Label>
|
|
<Input
|
|
id="discord_webhook"
|
|
value={discordSettings.webhookUrl}
|
|
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
|
|
placeholder="https://discord.com/api/webhooks/..."
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="discord_username" className="text-xs sm:text-sm">
|
|
사용자명
|
|
</Label>
|
|
<Input
|
|
id="discord_username"
|
|
value={discordSettings.username}
|
|
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, username: e.target.value }))}
|
|
placeholder="ERP 시스템"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="discord_avatar" className="text-xs sm:text-sm">
|
|
아바타 URL
|
|
</Label>
|
|
<Input
|
|
id="discord_avatar"
|
|
value={discordSettings.avatarUrl}
|
|
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, avatarUrl: e.target.value }))}
|
|
placeholder="https://example.com/avatar.png"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Slack 설정 */}
|
|
{formData.api_type === "slack" && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
|
<h4 className="text-xs font-semibold sm:text-sm">Slack 설정</h4>
|
|
<div>
|
|
<Label htmlFor="slack_webhook" className="text-xs sm:text-sm">
|
|
웹훅 URL *
|
|
</Label>
|
|
<Input
|
|
id="slack_webhook"
|
|
value={slackSettings.webhookUrl}
|
|
onChange={(e) => setSlackSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
|
|
placeholder="https://hooks.slack.com/services/..."
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="slack_channel" className="text-xs sm:text-sm">
|
|
채널
|
|
</Label>
|
|
<Input
|
|
id="slack_channel"
|
|
value={slackSettings.channel}
|
|
onChange={(e) => setSlackSettings((prev) => ({ ...prev, channel: e.target.value }))}
|
|
placeholder="#general"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="slack_username" className="text-xs sm:text-sm">
|
|
사용자명
|
|
</Label>
|
|
<Input
|
|
id="slack_username"
|
|
value={slackSettings.username}
|
|
onChange={(e) => setSlackSettings((prev) => ({ ...prev, username: e.target.value }))}
|
|
placeholder="ERP Bot"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카카오톡 설정 */}
|
|
{formData.api_type === "kakao-talk" && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
|
<h4 className="text-xs font-semibold sm:text-sm">카카오톡 설정</h4>
|
|
<div>
|
|
<Label htmlFor="kakao_token" className="text-xs sm:text-sm">
|
|
액세스 토큰 *
|
|
</Label>
|
|
<Input
|
|
id="kakao_token"
|
|
type="password"
|
|
value={kakaoSettings.accessToken}
|
|
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, accessToken: e.target.value }))}
|
|
placeholder="카카오 API 액세스 토큰"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="kakao_template" className="text-xs sm:text-sm">
|
|
템플릿 ID
|
|
</Label>
|
|
<Input
|
|
id="kakao_template"
|
|
value={kakaoSettings.templateId}
|
|
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, templateId: e.target.value }))}
|
|
placeholder="template_001"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 일반 API 설정 */}
|
|
{formData.api_type === "generic" && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4">
|
|
<h4 className="text-xs font-semibold sm:text-sm">일반 API 설정</h4>
|
|
<div>
|
|
<Label htmlFor="generic_url" className="text-xs sm:text-sm">
|
|
API URL *
|
|
</Label>
|
|
<Input
|
|
id="generic_url"
|
|
value={genericSettings.url}
|
|
onChange={(e) => setGenericSettings((prev) => ({ ...prev, url: e.target.value }))}
|
|
placeholder="https://api.example.com/webhook"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
|
<div>
|
|
<Label htmlFor="generic_method" className="text-xs sm:text-sm">
|
|
HTTP 메서드
|
|
</Label>
|
|
<Select
|
|
value={genericSettings.method}
|
|
onValueChange={(value) => setGenericSettings((prev) => ({ ...prev, method: value }))}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="GET" className="text-xs sm:text-sm">GET</SelectItem>
|
|
<SelectItem value="POST" className="text-xs sm:text-sm">POST</SelectItem>
|
|
<SelectItem value="PUT" className="text-xs sm:text-sm">PUT</SelectItem>
|
|
<SelectItem value="DELETE" className="text-xs sm:text-sm">DELETE</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="generic_timeout" className="text-xs sm:text-sm">
|
|
타임아웃 (ms)
|
|
</Label>
|
|
<Input
|
|
id="generic_timeout"
|
|
type="number"
|
|
value={genericSettings.timeout}
|
|
onChange={(e) => setGenericSettings((prev) => ({ ...prev, timeout: e.target.value }))}
|
|
placeholder="30000"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="generic_headers" className="text-xs sm:text-sm">
|
|
헤더 (JSON)
|
|
</Label>
|
|
<Textarea
|
|
id="generic_headers"
|
|
value={genericSettings.headers}
|
|
onChange={(e) => setGenericSettings((prev) => ({ ...prev, headers: e.target.value }))}
|
|
placeholder='{"Content-Type": "application/json"}'
|
|
rows={3}
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 다른 호출 타입들 (이메일, FTP, 큐) */}
|
|
{formData.call_type !== "rest-api" && (
|
|
<div className="rounded-lg border bg-muted/20 p-3 text-center text-xs text-muted-foreground sm:p-4 sm:text-sm">
|
|
{formData.call_type} 타입의 설정은 아직 구현되지 않았습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={loading}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={loading}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|