ERP-node/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx

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 />
&gt; &gt; .
</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> &gt; </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>
);
}