393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect } from "react";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Send, Mail, Eye, Plus, X, Loader2, CheckCircle } from "lucide-react";
|
||
|
|
import {
|
||
|
|
MailAccount,
|
||
|
|
MailTemplate,
|
||
|
|
getMailAccounts,
|
||
|
|
getMailTemplates,
|
||
|
|
sendMail,
|
||
|
|
extractTemplateVariables,
|
||
|
|
renderTemplateToHtml,
|
||
|
|
} from "@/lib/api/mail";
|
||
|
|
|
||
|
|
export default function MailSendPage() {
|
||
|
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||
|
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
|
||
|
|
// 폼 상태
|
||
|
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||
|
|
const [subject, setSubject] = useState<string>("");
|
||
|
|
const [recipients, setRecipients] = useState<string[]>([""]);
|
||
|
|
const [variables, setVariables] = useState<Record<string, string>>({});
|
||
|
|
|
||
|
|
// UI 상태
|
||
|
|
const [isSending, setIsSending] = useState(false);
|
||
|
|
const [showPreview, setShowPreview] = useState(false);
|
||
|
|
const [sendResult, setSendResult] = useState<{
|
||
|
|
success: boolean;
|
||
|
|
message: string;
|
||
|
|
} | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadData();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const loadData = async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const [accountsData, templatesData] = await Promise.all([
|
||
|
|
getMailAccounts(),
|
||
|
|
getMailTemplates(),
|
||
|
|
]);
|
||
|
|
setAccounts(Array.isArray(accountsData) ? accountsData : []);
|
||
|
|
setTemplates(Array.isArray(templatesData) ? templatesData : []);
|
||
|
|
|
||
|
|
// 기본값 설정
|
||
|
|
if (accountsData.length > 0 && !selectedAccountId) {
|
||
|
|
setSelectedAccountId(accountsData[0].id);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('데이터 로드 실패:', error);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||
|
|
const templateVariables = selectedTemplate
|
||
|
|
? extractTemplateVariables(selectedTemplate)
|
||
|
|
: [];
|
||
|
|
|
||
|
|
// 템플릿 선택 시 제목 자동 입력 및 변수 초기화
|
||
|
|
useEffect(() => {
|
||
|
|
if (selectedTemplate) {
|
||
|
|
setSubject(selectedTemplate.subject);
|
||
|
|
const initialVars: Record<string, string> = {};
|
||
|
|
templateVariables.forEach((varName) => {
|
||
|
|
initialVars[varName] = "";
|
||
|
|
});
|
||
|
|
setVariables(initialVars);
|
||
|
|
}
|
||
|
|
}, [selectedTemplateId]);
|
||
|
|
|
||
|
|
const addRecipient = () => {
|
||
|
|
setRecipients([...recipients, ""]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const removeRecipient = (index: number) => {
|
||
|
|
setRecipients(recipients.filter((_, i) => i !== index));
|
||
|
|
};
|
||
|
|
|
||
|
|
const updateRecipient = (index: number, value: string) => {
|
||
|
|
const newRecipients = [...recipients];
|
||
|
|
newRecipients[index] = value;
|
||
|
|
setRecipients(newRecipients);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSend = async () => {
|
||
|
|
// 유효성 검증
|
||
|
|
const validRecipients = recipients.filter((email) => email.trim() !== "");
|
||
|
|
if (validRecipients.length === 0) {
|
||
|
|
alert("수신자 이메일을 입력하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!selectedAccountId) {
|
||
|
|
alert("발송 계정을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!subject.trim()) {
|
||
|
|
alert("메일 제목을 입력하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!selectedTemplateId) {
|
||
|
|
alert("템플릿을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsSending(true);
|
||
|
|
setSendResult(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await sendMail({
|
||
|
|
accountId: selectedAccountId,
|
||
|
|
templateId: selectedTemplateId,
|
||
|
|
to: validRecipients,
|
||
|
|
subject,
|
||
|
|
variables,
|
||
|
|
});
|
||
|
|
|
||
|
|
setSendResult({
|
||
|
|
success: true,
|
||
|
|
message: `${result.accepted?.length || 0}개 발송 성공`,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 성공 후 초기화
|
||
|
|
setRecipients([""]);
|
||
|
|
setVariables({});
|
||
|
|
} catch (error) {
|
||
|
|
setSendResult({
|
||
|
|
success: false,
|
||
|
|
message: error instanceof Error ? error.message : "발송 실패",
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
setIsSending(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const previewHtml = selectedTemplate
|
||
|
|
? renderTemplateToHtml(selectedTemplate, variables)
|
||
|
|
: "";
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
|
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-gray-50">
|
||
|
|
<div className="w-full max-w-6xl mx-auto px-4 py-8 space-y-6">
|
||
|
|
{/* 페이지 제목 */}
|
||
|
|
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||
|
|
<h1 className="text-3xl font-bold text-gray-900">메일 발송</h1>
|
||
|
|
<p className="mt-2 text-gray-600">템플릿을 선택하여 메일을 발송합니다</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 메인 폼 */}
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
|
|
{/* 왼쪽: 발송 설정 */}
|
||
|
|
<div className="lg:col-span-2 space-y-6">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<Mail className="w-5 h-5 text-orange-500" />
|
||
|
|
발송 설정
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{/* 발송 계정 선택 */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
|
|
발송 계정 *
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={selectedAccountId}
|
||
|
|
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
>
|
||
|
|
<option value="">계정 선택</option>
|
||
|
|
{accounts
|
||
|
|
.filter((acc) => acc.status === "active")
|
||
|
|
.map((account) => (
|
||
|
|
<option key={account.id} value={account.id}>
|
||
|
|
{account.name} ({account.email})
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 템플릿 선택 */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
|
|
템플릿 *
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={selectedTemplateId}
|
||
|
|
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
>
|
||
|
|
<option value="">템플릿 선택</option>
|
||
|
|
{templates.map((template) => (
|
||
|
|
<option key={template.id} value={template.id}>
|
||
|
|
{template.name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 메일 제목 */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
|
|
메일 제목 *
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={subject}
|
||
|
|
onChange={(e) => setSubject(e.target.value)}
|
||
|
|
placeholder="예: 환영합니다!"
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 수신자 */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
|
|
수신자 이메일 *
|
||
|
|
</label>
|
||
|
|
<div className="space-y-2">
|
||
|
|
{recipients.map((email, index) => (
|
||
|
|
<div key={index} className="flex gap-2">
|
||
|
|
<input
|
||
|
|
type="email"
|
||
|
|
value={email}
|
||
|
|
onChange={(e) => updateRecipient(index, e.target.value)}
|
||
|
|
placeholder="example@email.com"
|
||
|
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
/>
|
||
|
|
{recipients.length > 1 && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => removeRecipient(index)}
|
||
|
|
className="text-red-500 hover:text-red-600"
|
||
|
|
>
|
||
|
|
<X className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={addRecipient}
|
||
|
|
className="w-full"
|
||
|
|
>
|
||
|
|
<Plus className="w-4 h-4 mr-2" />
|
||
|
|
수신자 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 템플릿 변수 */}
|
||
|
|
{templateVariables.length > 0 && (
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
|
|
템플릿 변수
|
||
|
|
</label>
|
||
|
|
<div className="space-y-2">
|
||
|
|
{templateVariables.map((varName) => (
|
||
|
|
<div key={varName}>
|
||
|
|
<label className="block text-xs text-gray-600 mb-1">
|
||
|
|
{varName}
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={variables[varName] || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
setVariables({ ...variables, [varName]: e.target.value })
|
||
|
|
}
|
||
|
|
placeholder={`{${varName}}`}
|
||
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 발송 버튼 */}
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<Button
|
||
|
|
onClick={() => setShowPreview(!showPreview)}
|
||
|
|
variant="outline"
|
||
|
|
className="flex-1"
|
||
|
|
disabled={!selectedTemplateId}
|
||
|
|
>
|
||
|
|
<Eye className="w-4 h-4 mr-2" />
|
||
|
|
미리보기
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleSend}
|
||
|
|
disabled={isSending || !selectedAccountId || !selectedTemplateId}
|
||
|
|
className="flex-1 bg-orange-500 hover:bg-orange-600"
|
||
|
|
>
|
||
|
|
{isSending ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
|
|
발송 중...
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Send className="w-4 h-4 mr-2" />
|
||
|
|
발송
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 발송 결과 */}
|
||
|
|
{sendResult && (
|
||
|
|
<Card
|
||
|
|
className={
|
||
|
|
sendResult.success
|
||
|
|
? "border-green-200 bg-green-50"
|
||
|
|
: "border-red-200 bg-red-50"
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<CardContent className="pt-6">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{sendResult.success ? (
|
||
|
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||
|
|
) : (
|
||
|
|
<X className="w-5 h-5 text-red-600" />
|
||
|
|
)}
|
||
|
|
<p
|
||
|
|
className={
|
||
|
|
sendResult.success ? "text-green-800" : "text-red-800"
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{sendResult.message}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 오른쪽: 미리보기 */}
|
||
|
|
<div className="lg:col-span-1">
|
||
|
|
<Card className="sticky top-4">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-base flex items-center gap-2">
|
||
|
|
<Eye className="w-4 h-4 text-orange-500" />
|
||
|
|
미리보기
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{showPreview && previewHtml ? (
|
||
|
|
<div className="border rounded-lg p-4 bg-white max-h-[600px] overflow-y-auto">
|
||
|
|
<div className="text-xs text-gray-500 mb-2">제목: {subject}</div>
|
||
|
|
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="text-center py-16 text-gray-400">
|
||
|
|
<Mail className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||
|
|
<p className="text-sm">
|
||
|
|
템플릿을 선택하고
|
||
|
|
<br />
|
||
|
|
미리보기 버튼을 클릭하세요
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|