ERP-node/frontend/app/(main)/admin/mail/send/page.tsx

1156 lines
45 KiB
TypeScript

"use client";
import React, { useState, useEffect, KeyboardEvent } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Send,
Mail,
FileText,
Eye,
X,
Loader2,
CheckCircle2,
AlertCircle,
Users,
UserPlus,
EyeOff,
Upload,
Paperclip,
File,
Settings,
ChevronRight,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
MailAccount,
MailTemplate,
getMailAccounts,
getMailTemplates,
sendMail,
extractTemplateVariables,
renderTemplateToHtml,
} from "@/lib/api/mail";
import { useToast } from "@/hooks/use-toast";
export default function MailSendPage() {
const router = useRouter();
const { toast } = useToast();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
// 폼 상태
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [to, setTo] = useState<string[]>([]);
const [cc, setCc] = useState<string[]>([]);
const [bcc, setBcc] = useState<string[]>([]);
const [toInput, setToInput] = useState<string>("");
const [ccInput, setCcInput] = useState<string>("");
const [bccInput, setBccInput] = useState<string>("");
const [subject, setSubject] = useState<string>("");
const [customHtml, setCustomHtml] = useState<string>("");
const [variables, setVariables] = useState<Record<string, string>>({});
const [showPreview, setShowPreview] = useState(false);
// 템플릿 변수
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
// 첨부파일
const [attachments, setAttachments] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
const [accountsData, templatesData] = await Promise.all([
getMailAccounts(),
getMailTemplates(),
]);
setAccounts(accountsData.filter((acc) => acc.status === "active"));
setTemplates(templatesData);
console.log('📦 데이터 로드 완료:', {
accounts: accountsData.length,
templates: templatesData.length,
templatesDetail: templatesData.map(t => ({
id: t.id,
name: t.name,
componentsCount: t.components?.length || 0
}))
});
} catch (error: unknown) {
const err = error as Error;
console.error('❌ 데이터 로드 실패:', err);
toast({
title: "데이터 로드 실패",
description: err.message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
// 템플릿 선택 시 (원본 다시 로드)
const handleTemplateChange = async (templateId: string) => {
console.log('🔄 템플릿 선택됨:', templateId);
// "__custom__"는 직접 작성을 의미
if (templateId === "__custom__") {
console.log('✏️ 직접 작성 모드');
setSelectedTemplateId("");
setTemplateVariables([]);
setVariables({});
return;
}
try {
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
console.log('🔃 원본 템플릿 API에서 재로드 중...');
const freshTemplates = await getMailTemplates();
const template = freshTemplates.find((t) => t.id === templateId);
console.log('📋 찾은 템플릿:', {
found: !!template,
templateId,
availableTemplates: freshTemplates.length,
template: template ? {
id: template.id,
name: template.name,
componentsCount: template.components?.length || 0
} : null
});
if (template) {
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
setTemplates(freshTemplates);
setSelectedTemplateId(templateId);
setSubject(template.subject);
const vars = extractTemplateVariables(template);
setTemplateVariables(vars);
const initialVars: Record<string, string> = {};
vars.forEach((v) => {
initialVars[v] = "";
});
setVariables(initialVars);
console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
subject: template.subject,
variables: vars
});
} else {
setSelectedTemplateId("");
setTemplateVariables([]);
setVariables({});
console.warn('⚠️ 템플릿을 찾을 수 없음');
}
} catch (error) {
console.error('❌ 템플릿 재로드 실패:', error);
toast({
title: "템플릿 로드 실패",
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 이메일 태그 입력 처리 (쉼표, 엔터, 공백 시 추가)
const handleEmailInput = (
e: KeyboardEvent<HTMLInputElement>,
type: "to" | "cc" | "bcc"
) => {
const input = type === "to" ? toInput : type === "cc" ? ccInput : bccInput;
const setInput =
type === "to" ? setToInput : type === "cc" ? setCcInput : setBccInput;
const emails = type === "to" ? to : type === "cc" ? cc : bcc;
const setEmails = type === "to" ? setTo : type === "cc" ? setCc : setBcc;
if (e.key === "Enter" || e.key === "," || e.key === " ") {
e.preventDefault();
const trimmedInput = input.trim().replace(/,$/, "");
if (trimmedInput && isValidEmail(trimmedInput)) {
if (!emails.includes(trimmedInput)) {
setEmails([...emails, trimmedInput]);
}
setInput("");
} else if (trimmedInput) {
toast({
title: "잘못된 이메일 형식",
description: `"${trimmedInput}"은(는) 올바른 이메일 주소가 아닙니다.`,
variant: "destructive",
});
}
}
};
// 이메일 주소 유효성 검사
const isValidEmail = (email: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
// 이메일 태그 제거
const removeEmail = (email: string, type: "to" | "cc" | "bcc") => {
if (type === "to") {
setTo(to.filter((e) => e !== email));
} else if (type === "cc") {
setCc(cc.filter((e) => e !== email));
} else {
setBcc(bcc.filter((e) => e !== email));
}
};
// 텍스트를 HTML로 변환 (줄바꿈 처리)
const convertTextToHtml = (text: string) => {
// 줄바꿈을 <br>로 변환하고 단락으로 감싸기
const paragraphs = text.split('\n\n').filter(p => p.trim());
const html = paragraphs
.map(p => `<p style="margin: 0 0 16px 0; line-height: 1.6;">${p.replace(/\n/g, '<br>')}</p>`)
.join('');
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
${html}
</div>
`;
};
// 메일 발송
const handleSendMail = async () => {
if (!selectedAccountId) {
toast({
title: "계정 선택 필요",
description: "발송할 메일 계정을 선택해주세요.",
variant: "destructive",
});
return;
}
if (to.length === 0) {
toast({
title: "수신자 필요",
description: "받는 사람을 1명 이상 입력해주세요.",
variant: "destructive",
});
return;
}
if (!subject.trim()) {
toast({
title: "제목 필요",
description: "메일 제목을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!selectedTemplateId && !customHtml.trim()) {
toast({
title: "내용 필요",
description: "템플릿을 선택하거나 메일 내용을 입력해주세요.",
variant: "destructive",
});
return;
}
try {
setSending(true);
// 텍스트를 HTML로 자동 변환
const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined;
// FormData 생성 (파일 첨부 지원)
const formData = new FormData();
formData.append("accountId", selectedAccountId);
if (selectedTemplateId) {
formData.append("templateId", selectedTemplateId);
// 🎯 수정된 템플릿 컴포넌트 전송
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
if (currentTemplate) {
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
}
}
formData.append("to", JSON.stringify(to));
if (cc.length > 0) {
formData.append("cc", JSON.stringify(cc));
}
if (bcc.length > 0) {
formData.append("bcc", JSON.stringify(bcc));
}
formData.append("subject", subject);
if (variables && Object.keys(variables).length > 0) {
formData.append("variables", JSON.stringify(variables));
}
if (htmlContent) {
formData.append("customHtml", htmlContent);
}
// 첨부파일 추가 (한글 파일명 처리)
attachments.forEach((file) => {
formData.append("attachments", file);
});
// 원본 파일명을 JSON으로 전송 (한글 파일명 보존)
if (attachments.length > 0) {
const originalFileNames = attachments.map(file => {
// 파일명 정규화 (NFD → NFC)
const normalizedName = file.name.normalize('NFC');
console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
return normalizedName;
});
formData.append("fileNames", JSON.stringify(originalFileNames));
console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
}
// API 호출 (FormData 전송)
const authToken = localStorage.getItem("authToken");
if (!authToken) {
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
}
const response = await fetch("/api/mail/send/simple", {
method: "POST",
headers: {
Authorization: `Bearer ${authToken}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "메일 발송 실패");
}
// 성공 토스트
toast({
title: (
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<span> !</span>
</div>
) as any,
description: `${to.length}${cc.length > 0 ? ` (참조 ${cc.length}명)` : ""}${bcc.length > 0 ? ` (숨은참조 ${bcc.length}명)` : ""}${attachments.length > 0 ? ` (첨부파일 ${attachments.length}개)` : ""}에게 메일이 성공적으로 발송되었습니다.`,
className: "border-green-500 bg-green-50",
});
// 폼 초기화
setTo([]);
setCc([]);
setBcc([]);
setToInput("");
setCcInput("");
setBccInput("");
setSubject("");
setCustomHtml("");
setVariables({});
setSelectedTemplateId("");
setAttachments([]);
} catch (error: unknown) {
const err = error as Error;
toast({
title: (
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<span> </span>
</div>
) as any,
description: err.message || "메일 발송 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setSending(false);
}
};
// 파일 첨부 관련 함수
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
addFiles(files);
// input 초기화
e.target.value = "";
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
addFiles(files);
};
const addFiles = (files: File[]) => {
// 파일 검증
const validFiles = files.filter((file) => {
// 파일 크기 제한 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast({
title: "파일 크기 초과",
description: `${file.name}은(는) 10MB를 초과합니다.`,
variant: "destructive",
});
return false;
}
// 위험한 확장자 차단
const dangerousExtensions = [".exe", ".bat", ".cmd", ".sh", ".ps1", ".msi"];
const extension = file.name.toLowerCase().substring(file.name.lastIndexOf("."));
if (dangerousExtensions.includes(extension)) {
toast({
title: "허용되지 않는 파일 형식",
description: `${extension} 파일은 첨부할 수 없습니다.`,
variant: "destructive",
});
return false;
}
return true;
});
// 최대 5개 제한
const totalFiles = attachments.length + validFiles.length;
if (totalFiles > 5) {
toast({
title: "파일 개수 초과",
description: "최대 5개까지만 첨부할 수 있습니다.",
variant: "destructive",
});
return;
}
setAttachments([...attachments, ...validFiles]);
};
const removeFile = (index: number) => {
setAttachments(attachments.filter((_, i) => i !== index));
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
};
// 미리보기
const handlePreview = () => {
console.log('👁️ 미리보기 토글:', {
current: showPreview,
willBe: !showPreview,
selectedTemplateId,
hasCustomHtml: !!customHtml
});
setShowPreview(!showPreview);
};
const getPreviewHtml = () => {
if (selectedTemplateId) {
const template = templates.find((t) => t.id === selectedTemplateId);
if (template) {
console.log('🎨 템플릿 미리보기:', {
templateId: selectedTemplateId,
templateName: template.name,
componentsCount: template.components?.length || 0,
components: template.components,
variables
});
const html = renderTemplateToHtml(template, variables);
console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
// 추가 메시지가 있으면 병합
if (customHtml && customHtml.trim()) {
const additionalContent = `
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
${convertTextToHtml(customHtml)}
</div>
`;
return html + additionalContent;
}
return html;
}
}
// 일반 텍스트를 HTML로 변환하여 미리보기
return customHtml ? convertTextToHtml(customHtml) : "";
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-6 space-y-8 bg-background min-h-screen">
{/* 헤더 */}
<div className="bg-card rounded-lg border p-6 space-y-4">
{/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium"> </span>
</nav>
<Separator />
{/* 제목 */}
<div>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">릿 </p>
</div>
</div>
<div className={`grid gap-8 ${showPreview ? 'lg:grid-cols-3' : 'grid-cols-1'}`}>
{/* 메일 작성 폼 */}
<div className={showPreview ? 'lg:col-span-2' : 'col-span-1'}>
<div className="space-y-8">
{/* 발송 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 발송 계정 선택 */}
<div>
<Label htmlFor="account"> *</Label>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger id="account">
<SelectValue placeholder="발송할 메일 계정을 선택하세요" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name} ({account.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 템플릿 선택 */}
<div>
<Label htmlFor="template">릿 ()</Label>
<Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
<SelectTrigger id="template">
<SelectValue placeholder="템플릿을 선택하거나 직접 작성하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__custom__"> </SelectItem>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 수신자 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 받는 사람 */}
<div>
<Label htmlFor="to" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
*
</Label>
<div className="space-y-2">
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
{to.map((email) => (
<div
key={email}
className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded-md text-sm"
>
<span>{email}</span>
<button
onClick={() => removeEmail(email, "to")}
className="hover:bg-blue-200 rounded p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<input
id="to"
type="text"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
onKeyDown={(e) => handleEmailInput(e, "to")}
placeholder={to.length === 0 ? "이메일 주소 입력 후 엔터, 쉼표, 스페이스" : ""}
className="flex-1 outline-none min-w-[200px] text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
💡 , (,),
</p>
</div>
</div>
{/* 참조 (CC) */}
<div>
<Label htmlFor="cc" className="flex items-center gap-2">
<UserPlus className="w-4 h-4" />
(CC)
</Label>
<div className="space-y-2">
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
{cc.map((email) => (
<div
key={email}
className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded-md text-sm"
>
<span>{email}</span>
<button
onClick={() => removeEmail(email, "cc")}
className="hover:bg-green-200 rounded p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<input
id="cc"
type="text"
value={ccInput}
onChange={(e) => setCcInput(e.target.value)}
onKeyDown={(e) => handleEmailInput(e, "cc")}
placeholder={cc.length === 0 ? "참조로 받을 이메일 주소" : ""}
className="flex-1 outline-none min-w-[200px] text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
{/* 숨은참조 (BCC) */}
<div>
<Label htmlFor="bcc" className="flex items-center gap-2">
<EyeOff className="w-4 h-4" />
(BCC)
</Label>
<div className="space-y-2">
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
{bcc.map((email) => (
<div
key={email}
className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded-md text-sm"
>
<span>{email}</span>
<button
onClick={() => removeEmail(email, "bcc")}
className="hover:bg-purple-200 rounded p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<input
id="bcc"
type="text"
value={bccInput}
onChange={(e) => setBccInput(e.target.value)}
onKeyDown={(e) => handleEmailInput(e, "bcc")}
placeholder={bcc.length === 0 ? "숨은참조로 받을 이메일 주소" : ""}
className="flex-1 outline-none min-w-[200px] text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
🔒 ()
</p>
</div>
</div>
</CardContent>
</Card>
{/* 메일 내용 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 제목 */}
<div>
<Label htmlFor="subject"> *</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="메일 제목을 입력하세요"
/>
</div>
{/* 템플릿 변수 입력 */}
{templateVariables.length > 0 && (
<div className="space-y-2">
<Label>릿 </Label>
{templateVariables.map((varName) => (
<div key={varName}>
<Label htmlFor={`var-${varName}`} className="text-xs">
{varName}
</Label>
<Input
id={`var-${varName}`}
value={variables[varName] || ""}
onChange={(e) =>
setVariables({ ...variables, [varName]: e.target.value })
}
placeholder={`{${varName}} 변수 값`}
/>
</div>
))}
</div>
)}
{/* 템플릿 편집 가능한 미리보기 */}
{selectedTemplateId && (() => {
const template = templates.find((t) => t.id === selectedTemplateId);
if (template) {
return (
<div className="border rounded-lg overflow-hidden">
<div className="bg-gradient-to-r from-orange-50 to-amber-50 border-b px-4 py-3 flex items-center justify-between">
<Label className="font-semibold text-foreground flex items-center gap-2">
<FileText className="w-4 h-4" />
( )
</Label>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/admin/mail/templates`)}
className="flex items-center gap-1"
>
<Settings className="w-3 h-3" />
</Button>
</div>
<div className="bg-card p-6 space-y-4">
{template.components.map((component, index) => {
// 변수 치환 및 HTML 태그 제거
let content = component.content || '';
let text = component.text || '';
// HTML 태그 제거 (일반 텍스트만 표시)
const stripHtml = (html: string) => {
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
};
content = stripHtml(content);
if (variables) {
Object.entries(variables).forEach(([key, value]) => {
content = content.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
});
}
switch (component.type) {
case 'text':
return (
<div key={component.id} className="group relative">
<Textarea
value={content}
onChange={(e) => {
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, content: `<p>${e.target.value}</p>` } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
}}
onFocus={(e) => {
// 🎯 클릭 시 placeholder 같은 텍스트 자동 제거
const currentValue = e.target.value.trim();
if (currentValue === '텍스트를 입력하세요' ||
currentValue === '텍스트를 입력하세요...' ||
currentValue === '여기를 클릭하여 내용을 입력하세요' ||
currentValue === '여기를 클릭하여 내용을 입력하세요...') {
e.target.value = '';
// state도 업데이트
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, content: '' } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
}
}}
className="min-h-[100px] resize-none bg-card border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="여기를 클릭하여 내용을 입력하세요..."
/>
</div>
);
case 'button':
return (
<div key={component.id} className="group relative">
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-sm text-muted-foreground mb-1 block"> </Label>
<Input
value={text}
onChange={(e) => {
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, text: e.target.value } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
}}
className="bg-card border border focus:ring-2 focus:ring-orange-500"
placeholder="버튼에 표시될 텍스트"
/>
</div>
<button
style={{
...component.styles,
padding: '12px 24px',
borderRadius: '6px',
fontWeight: '600',
marginTop: '20px'
}}
className="whitespace-nowrap"
>
{text || '버튼'}
</button>
</div>
</div>
);
case 'image':
return (
<div key={component.id} className="group relative">
<div className="space-y-3">
<div className="relative">
<img
src={component.src}
alt="메일 이미지"
className="w-full rounded-lg border border"
/>
<div className="absolute top-2 right-2 flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e: any) => {
const file = e.target?.files?.[0];
if (file) {
// 파일을 Base64로 변환
const reader = new FileReader();
reader.onload = (event) => {
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, src: event.target?.result as string } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
};
reader.readAsDataURL(file);
}
};
input.click();
}}
className="bg-card/90 backdrop-blur-sm shadow-lg"
>
<Upload className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
</div>
</div>
);
case 'spacer':
return (
<div
key={component.id}
style={{ height: `${component.height || 20}px` }}
className="bg-background rounded flex items-center justify-center text-xs text-gray-400"
>
</div>
);
default:
return null;
}
})}
</div>
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-4 py-3 border-t border-green-200">
<p className="text-sm text-green-800 flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 text-green-600" />
</p>
</div>
</div>
);
}
return null;
})()}
{/* 메일 내용 입력 - 항상 표시 */}
<div>
<Label htmlFor="customHtml">
{selectedTemplateId ? "추가 메시지 (선택)" : "내용 *"}
</Label>
<Textarea
id="customHtml"
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
placeholder={
selectedTemplateId
? "템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
: "메일 내용을 입력하세요\n\n줄바꿈은 자동으로 처리됩니다."
}
rows={10}
/>
<p className="text-xs text-muted-foreground mt-1">
{selectedTemplateId ? (
<>💡 릿 </>
) : (
<>💡 </>
)}
</p>
</div>
</CardContent>
</Card>
{/* 파일 첨부 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Paperclip className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 드래그 앤 드롭 영역 */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragging
? "border-primary bg-primary/5"
: "border hover:border-primary/50"
}`}
onClick={() => document.getElementById("file-input")?.click()}
>
<input
id="file-input"
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-3" />
<p className="text-sm text-muted-foreground mb-1">
</p>
<p className="text-xs text-muted-foreground">
5, 10MB
</p>
</div>
{/* 첨부된 파일 목록 */}
{attachments.length > 0 && (
<div className="space-y-2">
<Label> ({attachments.length})</Label>
<div className="space-y-2">
{attachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-background rounded-lg border"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<File className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
className="flex-shrink-0 text-red-500 hover:text-red-600 hover:bg-red-50"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 발송 버튼 */}
<div className="flex gap-3">
<Button
onClick={handleSendMail}
disabled={sending}
className="flex-1"
size="lg"
>
{sending ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
...
</>
) : (
<>
<Send className="w-5 h-5 mr-2" />
</>
)}
</Button>
<Button
onClick={handlePreview}
variant="outline"
size="lg"
>
<Eye className="w-5 h-5 mr-2" />
</Button>
</div>
</div>
</div>
{/* 미리보기 패널 */}
{showPreview && (
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5" />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(false)}
>
<X className="w-4 h-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<div className="border rounded-lg p-4 bg-card overflow-auto max-h-[70vh]">
<div className="space-y-2 mb-4 pb-4 border-b">
<div className="text-sm">
<span className="font-semibold"> :</span> {to.join(", ") || "-"}
</div>
{cc.length > 0 && (
<div className="text-sm">
<span className="font-semibold">:</span> {cc.join(", ")}
</div>
)}
{bcc.length > 0 && (
<div className="text-sm">
<span className="font-semibold">:</span> {bcc.join(", ")}
</div>
)}
<div className="text-sm">
<span className="font-semibold">:</span> {subject || "-"}
</div>
{attachments.length > 0 && (
<div className="text-sm">
<span className="font-semibold">:</span> {attachments.length}
<div className="ml-4 mt-1 space-y-1">
{attachments.map((file, index) => (
<div key={index} className="flex items-center gap-2 text-xs text-muted-foreground">
<File className="w-3 h-3" />
<span className="truncate">{file.name}</span>
<span className="text-gray-400">({formatFileSize(file.size)})</span>
</div>
))}
</div>
</div>
)}
</div>
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
);
}