655 lines
25 KiB
TypeScript
655 lines
25 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 메일 발송 노드 속성 편집
|
|
* - 메일관리에서 등록한 계정을 선택하여 발송
|
|
* - 변수 태그 에디터로 본문 편집
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Plus, Trash2, Mail, Server, FileText, Settings, RefreshCw, CheckCircle, AlertCircle, User } from "lucide-react";
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import { getMailAccounts, type MailAccount } from "@/lib/api/mail";
|
|
import type { EmailActionNodeData } from "@/types/node-editor";
|
|
import { VariableTagEditor, type VariableInfo } from "../../editors/VariableTagEditor";
|
|
|
|
interface EmailActionPropertiesProps {
|
|
nodeId: string;
|
|
data: EmailActionNodeData;
|
|
}
|
|
|
|
export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesProps) {
|
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
|
|
|
// 메일 계정 목록
|
|
const [mailAccounts, setMailAccounts] = useState<MailAccount[]>([]);
|
|
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
|
const [accountError, setAccountError] = useState<string | null>(null);
|
|
|
|
// 🆕 플로우에서 사용 가능한 변수 목록 계산
|
|
const availableVariables = useMemo<VariableInfo[]>(() => {
|
|
const variables: VariableInfo[] = [];
|
|
|
|
// 기본 시스템 변수
|
|
variables.push(
|
|
{ name: "timestamp", displayName: "현재 시간", description: "메일 발송 시점의 타임스탬프" },
|
|
{ name: "sourceData", displayName: "소스 데이터", description: "전체 소스 데이터 (JSON)" }
|
|
);
|
|
|
|
// 현재 노드에 연결된 소스 노드들에서 필드 정보 수집
|
|
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
|
|
|
for (const edge of incomingEdges) {
|
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
|
if (!sourceNode) continue;
|
|
|
|
const nodeData = sourceNode.data as any;
|
|
|
|
// 테이블 소스 노드인 경우
|
|
if (sourceNode.type === "tableSource" && nodeData.fields) {
|
|
const tableName = nodeData.tableName || "테이블";
|
|
nodeData.fields.forEach((field: any) => {
|
|
variables.push({
|
|
name: field.name,
|
|
displayName: field.displayName || field.label || field.name,
|
|
type: field.type,
|
|
description: `${tableName} 테이블의 필드`,
|
|
});
|
|
});
|
|
}
|
|
|
|
// 외부 DB 소스 노드인 경우
|
|
if (sourceNode.type === "externalDBSource" && nodeData.fields) {
|
|
const tableName = nodeData.tableName || "외부 테이블";
|
|
nodeData.fields.forEach((field: any) => {
|
|
variables.push({
|
|
name: field.name,
|
|
displayName: field.displayName || field.label || field.name,
|
|
type: field.type,
|
|
description: `${tableName} (외부 DB) 필드`,
|
|
});
|
|
});
|
|
}
|
|
|
|
// REST API 소스 노드인 경우
|
|
if (sourceNode.type === "restAPISource" && nodeData.responseFields) {
|
|
nodeData.responseFields.forEach((field: any) => {
|
|
variables.push({
|
|
name: field.name,
|
|
displayName: field.displayName || field.label || field.name,
|
|
type: field.type,
|
|
description: "REST API 응답 필드",
|
|
});
|
|
});
|
|
}
|
|
|
|
// 데이터 변환 노드인 경우 - 출력 필드 추가
|
|
if (sourceNode.type === "dataTransform" && nodeData.transformations) {
|
|
nodeData.transformations.forEach((transform: any) => {
|
|
if (transform.targetField) {
|
|
variables.push({
|
|
name: transform.targetField,
|
|
displayName: transform.targetField,
|
|
description: "데이터 변환 결과 필드",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 중복 제거
|
|
const uniqueVariables = variables.filter(
|
|
(v, index, self) => index === self.findIndex((t) => t.name === v.name)
|
|
);
|
|
|
|
return uniqueVariables;
|
|
}, [nodes, edges, nodeId]);
|
|
|
|
// 로컬 상태
|
|
const [displayName, setDisplayName] = useState(data.displayName || "메일 발송");
|
|
|
|
// 계정 선택
|
|
const [selectedAccountId, setSelectedAccountId] = useState(data.accountId || "");
|
|
|
|
// 🆕 수신자 컴포넌트 사용 여부
|
|
const [useRecipientComponent, setUseRecipientComponent] = useState(data.useRecipientComponent ?? false);
|
|
const [recipientToField, setRecipientToField] = useState(data.recipientToField || "mailTo");
|
|
const [recipientCcField, setRecipientCcField] = useState(data.recipientCcField || "mailCc");
|
|
|
|
// 메일 내용
|
|
const [to, setTo] = useState(data.to || "");
|
|
const [cc, setCc] = useState(data.cc || "");
|
|
const [bcc, setBcc] = useState(data.bcc || "");
|
|
const [subject, setSubject] = useState(data.subject || "");
|
|
const [body, setBody] = useState(data.body || "");
|
|
const [bodyType, setBodyType] = useState<"text" | "html">(data.bodyType || "text");
|
|
|
|
// 고급 설정
|
|
const [replyTo, setReplyTo] = useState(data.replyTo || "");
|
|
const [priority, setPriority] = useState<"high" | "normal" | "low">(data.priority || "normal");
|
|
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000");
|
|
const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "3");
|
|
|
|
// 메일 계정 목록 로드
|
|
const loadMailAccounts = useCallback(async () => {
|
|
setIsLoadingAccounts(true);
|
|
setAccountError(null);
|
|
try {
|
|
const accounts = await getMailAccounts();
|
|
setMailAccounts(accounts.filter(acc => acc.status === 'active'));
|
|
} catch (error) {
|
|
console.error("메일 계정 로드 실패:", error);
|
|
setAccountError("메일 계정을 불러오는데 실패했습니다");
|
|
} finally {
|
|
setIsLoadingAccounts(false);
|
|
}
|
|
}, []);
|
|
|
|
// 컴포넌트 마운트 시 메일 계정 로드
|
|
useEffect(() => {
|
|
loadMailAccounts();
|
|
}, [loadMailAccounts]);
|
|
|
|
// 데이터 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setDisplayName(data.displayName || "메일 발송");
|
|
setSelectedAccountId(data.accountId || "");
|
|
setUseRecipientComponent(data.useRecipientComponent ?? false);
|
|
setRecipientToField(data.recipientToField || "mailTo");
|
|
setRecipientCcField(data.recipientCcField || "mailCc");
|
|
setTo(data.to || "");
|
|
setCc(data.cc || "");
|
|
setBcc(data.bcc || "");
|
|
setSubject(data.subject || "");
|
|
setBody(data.body || "");
|
|
setBodyType(data.bodyType || "text");
|
|
setReplyTo(data.replyTo || "");
|
|
setPriority(data.priority || "normal");
|
|
setTimeout(data.options?.timeout?.toString() || "30000");
|
|
setRetryCount(data.options?.retryCount?.toString() || "3");
|
|
}, [data]);
|
|
|
|
// 선택된 계정 정보
|
|
const selectedAccount = mailAccounts.find(acc => acc.id === selectedAccountId);
|
|
|
|
// 노드 업데이트 함수
|
|
const updateNodeData = useCallback(
|
|
(updates: Partial<EmailActionNodeData>) => {
|
|
updateNode(nodeId, {
|
|
...data,
|
|
...updates,
|
|
});
|
|
},
|
|
[nodeId, data, updateNode]
|
|
);
|
|
|
|
// 표시명 변경
|
|
const handleDisplayNameChange = (value: string) => {
|
|
setDisplayName(value);
|
|
updateNodeData({ displayName: value });
|
|
};
|
|
|
|
// 계정 선택 변경
|
|
const handleAccountChange = (accountId: string) => {
|
|
setSelectedAccountId(accountId);
|
|
const account = mailAccounts.find(acc => acc.id === accountId);
|
|
updateNodeData({
|
|
accountId,
|
|
// 계정의 이메일을 발신자로 자동 설정
|
|
from: account?.email || ""
|
|
});
|
|
};
|
|
|
|
// 메일 내용 업데이트
|
|
const updateMailContent = useCallback(() => {
|
|
updateNodeData({
|
|
to,
|
|
cc: cc || undefined,
|
|
bcc: bcc || undefined,
|
|
subject,
|
|
body,
|
|
bodyType,
|
|
replyTo: replyTo || undefined,
|
|
priority,
|
|
});
|
|
}, [to, cc, bcc, subject, body, bodyType, replyTo, priority, updateNodeData]);
|
|
|
|
// 옵션 업데이트
|
|
const updateOptions = useCallback(() => {
|
|
updateNodeData({
|
|
options: {
|
|
timeout: parseInt(timeout) || 30000,
|
|
retryCount: parseInt(retryCount) || 3,
|
|
},
|
|
});
|
|
}, [timeout, retryCount, updateNodeData]);
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
{/* 표시명 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">표시명</Label>
|
|
<Input
|
|
value={displayName}
|
|
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
|
placeholder="메일 발송"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<Tabs defaultValue="account" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="account" className="text-xs">
|
|
<User className="mr-1 h-3 w-3" />
|
|
계정
|
|
</TabsTrigger>
|
|
<TabsTrigger value="mail" className="text-xs">
|
|
<Mail className="mr-1 h-3 w-3" />
|
|
메일
|
|
</TabsTrigger>
|
|
<TabsTrigger value="content" className="text-xs">
|
|
<FileText className="mr-1 h-3 w-3" />
|
|
본문
|
|
</TabsTrigger>
|
|
<TabsTrigger value="options" className="text-xs">
|
|
<Settings className="mr-1 h-3 w-3" />
|
|
옵션
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 계정 선택 탭 */}
|
|
<TabsContent value="account" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">발송 계정 선택 *</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={loadMailAccounts}
|
|
disabled={isLoadingAccounts}
|
|
className="h-6 px-2"
|
|
>
|
|
<RefreshCw className={`h-3 w-3 ${isLoadingAccounts ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
</div>
|
|
|
|
{accountError && (
|
|
<div className="flex items-center gap-2 text-xs text-red-500">
|
|
<AlertCircle className="h-3 w-3" />
|
|
{accountError}
|
|
</div>
|
|
)}
|
|
|
|
<Select
|
|
value={selectedAccountId}
|
|
onValueChange={handleAccountChange}
|
|
disabled={isLoadingAccounts}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue placeholder={isLoadingAccounts ? "로딩 중..." : "메일 계정을 선택하세요"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{mailAccounts.length === 0 ? (
|
|
<div className="p-2 text-xs text-gray-500">
|
|
등록된 메일 계정이 없습니다.
|
|
<br />
|
|
관리자 > 메일관리 > 계정관리에서 추가하세요.
|
|
</div>
|
|
) : (
|
|
mailAccounts.map((account) => (
|
|
<SelectItem key={account.id} value={account.id}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{account.name}</span>
|
|
<span className="text-xs text-gray-500">({account.email})</span>
|
|
</div>
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 선택된 계정 정보 표시 */}
|
|
{selectedAccount && (
|
|
<Card className="bg-green-50 border-green-200">
|
|
<CardContent className="p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-green-700">
|
|
<CheckCircle className="h-4 w-4" />
|
|
<span className="text-sm font-medium">선택된 계정</span>
|
|
</div>
|
|
<div className="text-xs space-y-1 text-green-800">
|
|
<div><strong>이름:</strong> {selectedAccount.name}</div>
|
|
<div><strong>이메일:</strong> {selectedAccount.email}</div>
|
|
<div><strong>SMTP:</strong> {selectedAccount.smtpHost}:{selectedAccount.smtpPort}</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!selectedAccount && mailAccounts.length > 0 && (
|
|
<Card className="bg-yellow-50 border-yellow-200">
|
|
<CardContent className="p-3 text-xs text-yellow-700">
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span>메일 발송을 위해 계정을 선택해주세요.</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{mailAccounts.length === 0 && !isLoadingAccounts && (
|
|
<Card className="bg-gray-50">
|
|
<CardContent className="p-3 text-xs text-gray-600">
|
|
<div className="space-y-2">
|
|
<div className="font-medium">메일 계정 등록 방법:</div>
|
|
<ol className="list-decimal list-inside space-y-1">
|
|
<li>관리자 메뉴로 이동</li>
|
|
<li>메일관리 > 계정관리 선택</li>
|
|
<li>새 계정 추가 버튼 클릭</li>
|
|
<li>SMTP 정보 입력 후 저장</li>
|
|
</ol>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 메일 설정 탭 */}
|
|
<TabsContent value="mail" className="space-y-3 pt-3">
|
|
{/* 발신자는 선택된 계정에서 자동으로 설정됨 */}
|
|
{selectedAccount && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">발신자 (From)</Label>
|
|
<div className="h-8 px-3 py-2 text-sm bg-gray-100 rounded-md border flex items-center">
|
|
{selectedAccount.email}
|
|
</div>
|
|
<p className="text-xs text-gray-500">선택한 계정의 이메일 주소가 자동으로 사용됩니다.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 🆕 수신자 컴포넌트 사용 옵션 */}
|
|
<Card className={useRecipientComponent ? "bg-blue-50 border-blue-200" : "bg-gray-50"}>
|
|
<CardContent className="p-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">수신자 컴포넌트 사용</Label>
|
|
<p className="text-xs text-gray-500">
|
|
화면에 배치한 "메일 수신자 선택" 컴포넌트의 값을 자동으로 사용합니다.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={useRecipientComponent}
|
|
onCheckedChange={(checked) => {
|
|
setUseRecipientComponent(checked);
|
|
if (checked) {
|
|
// 체크 시 자동으로 변수 설정
|
|
updateNodeData({
|
|
useRecipientComponent: true,
|
|
recipientToField,
|
|
recipientCcField,
|
|
to: `{{${recipientToField}}}`,
|
|
cc: `{{${recipientCcField}}}`,
|
|
});
|
|
setTo(`{{${recipientToField}}}`);
|
|
setCc(`{{${recipientCcField}}}`);
|
|
} else {
|
|
updateNodeData({
|
|
useRecipientComponent: false,
|
|
to: "",
|
|
cc: "",
|
|
});
|
|
setTo("");
|
|
setCc("");
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 필드명 설정 (수신자 컴포넌트 사용 시) */}
|
|
{useRecipientComponent && (
|
|
<div className="space-y-2 pt-2 border-t border-blue-200">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">수신자 필드명</Label>
|
|
<Input
|
|
value={recipientToField}
|
|
onChange={(e) => {
|
|
const newField = e.target.value;
|
|
setRecipientToField(newField);
|
|
setTo(`{{${newField}}}`);
|
|
updateNodeData({
|
|
recipientToField: newField,
|
|
to: `{{${newField}}}`,
|
|
});
|
|
}}
|
|
placeholder="mailTo"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">참조 필드명</Label>
|
|
<Input
|
|
value={recipientCcField}
|
|
onChange={(e) => {
|
|
const newField = e.target.value;
|
|
setRecipientCcField(newField);
|
|
setCc(`{{${newField}}}`);
|
|
updateNodeData({
|
|
recipientCcField: newField,
|
|
cc: `{{${newField}}}`,
|
|
});
|
|
}}
|
|
placeholder="mailCc"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-blue-600 bg-blue-100 p-2 rounded">
|
|
<strong>자동 설정됨:</strong>
|
|
<br />
|
|
수신자: <code className="bg-white px-1 rounded">{`{{${recipientToField}}}`}</code>
|
|
<br />
|
|
참조: <code className="bg-white px-1 rounded">{`{{${recipientCcField}}}`}</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 수신자 직접 입력 (컴포넌트 미사용 시) */}
|
|
{!useRecipientComponent && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">수신자 (To) *</Label>
|
|
<Input
|
|
value={to}
|
|
onChange={(e) => setTo(e.target.value)}
|
|
onBlur={updateMailContent}
|
|
placeholder="recipient@example.com (쉼표로 구분, {{변수}} 사용 가능)"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">참조 (CC)</Label>
|
|
<Input
|
|
value={cc}
|
|
onChange={(e) => setCc(e.target.value)}
|
|
onBlur={updateMailContent}
|
|
placeholder="cc@example.com"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">숨은 참조 (BCC)</Label>
|
|
<Input
|
|
value={bcc}
|
|
onChange={(e) => setBcc(e.target.value)}
|
|
onBlur={updateMailContent}
|
|
placeholder="bcc@example.com"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">회신 주소 (Reply-To)</Label>
|
|
<Input
|
|
value={replyTo}
|
|
onChange={(e) => setReplyTo(e.target.value)}
|
|
onBlur={updateMailContent}
|
|
placeholder="reply@example.com"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">우선순위</Label>
|
|
<Select value={priority} onValueChange={(v: "high" | "normal" | "low") => {
|
|
setPriority(v);
|
|
updateNodeData({ priority: v });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="high">높음</SelectItem>
|
|
<SelectItem value="normal">보통</SelectItem>
|
|
<SelectItem value="low">낮음</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 본문 탭 */}
|
|
<TabsContent value="content" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">제목 *</Label>
|
|
<Input
|
|
value={subject}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
onBlur={updateMailContent}
|
|
placeholder="메일 제목 ({{변수}} 사용 가능)"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">본문 형식</Label>
|
|
<Select value={bodyType} onValueChange={(v: "text" | "html") => {
|
|
setBodyType(v);
|
|
updateNodeData({ bodyType: v });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트 (변수 태그 에디터)</SelectItem>
|
|
<SelectItem value="html">HTML (직접 입력)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">본문 내용</Label>
|
|
|
|
{/* 텍스트 형식: 변수 태그 에디터 사용 */}
|
|
{bodyType === "text" && (
|
|
<VariableTagEditor
|
|
value={body}
|
|
onChange={(newBody) => {
|
|
setBody(newBody);
|
|
updateNodeData({ body: newBody });
|
|
}}
|
|
variables={availableVariables}
|
|
placeholder="메일 본문을 입력하세요. @ 또는 / 키로 변수를 삽입할 수 있습니다."
|
|
minHeight="200px"
|
|
/>
|
|
)}
|
|
|
|
{/* HTML 형식: 직접 입력 */}
|
|
{bodyType === "html" && (
|
|
<Textarea
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
onBlur={updateMailContent}
|
|
placeholder="<html><body>...</body></html>"
|
|
className="min-h-[200px] text-sm font-mono"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 변수 안내 (HTML 모드에서만 표시) */}
|
|
{bodyType === "html" && (
|
|
<Card className="bg-gray-50">
|
|
<CardContent className="p-3 text-xs text-gray-600">
|
|
<div className="font-medium mb-1">사용 가능한 템플릿 변수:</div>
|
|
{availableVariables.slice(0, 5).map((v) => (
|
|
<code key={v.name} className="block">
|
|
{`{{${v.name}}}`} - {v.displayName}
|
|
</code>
|
|
))}
|
|
{availableVariables.length > 5 && (
|
|
<span className="text-gray-400">...외 {availableVariables.length - 5}개</span>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 변수 태그 에디터 안내 (텍스트 모드에서만 표시) */}
|
|
{bodyType === "text" && (
|
|
<Card className="bg-blue-50 border-blue-200">
|
|
<CardContent className="p-3 text-xs text-blue-700">
|
|
<div className="font-medium mb-1">변수 삽입 방법:</div>
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
<li><kbd className="bg-blue-100 px-1 rounded">@</kbd> 또는 <kbd className="bg-blue-100 px-1 rounded">/</kbd> 키를 눌러 변수 목록 열기</li>
|
|
<li>툴바의 "변수 삽입" 버튼 클릭</li>
|
|
<li>삽입된 변수는 파란색 태그로 표시됩니다</li>
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 옵션 탭 */}
|
|
<TabsContent value="options" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">타임아웃 (ms)</Label>
|
|
<Input
|
|
type="number"
|
|
value={timeout}
|
|
onChange={(e) => setTimeout(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="30000"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">재시도 횟수</Label>
|
|
<Input
|
|
type="number"
|
|
value={retryCount}
|
|
onChange={(e) => setRetryCount(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="3"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|