- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
589 lines
22 KiB
TypeScript
589 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 { showErrorToast } from "@/lib/utils/toastUtils";
|
|
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);
|
|
showErrorToast("외부 호출 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
} 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>
|
|
);
|
|
}
|