메일 관리 작업 저장용 커밋
This commit is contained in:
392
frontend/app/(main)/admin/mail/send/page.tsx
Normal file
392
frontend/app/(main)/admin/mail/send/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user