2025-10-01 16:15:53 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
import React, { useState, useEffect, KeyboardEvent } from "react";
|
2025-10-01 16:15:53 +09:00
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-10-02 18:22:58 +09:00
|
|
|
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,
|
2025-10-13 15:17:34 +09:00
|
|
|
Settings,
|
|
|
|
|
ChevronRight,
|
2025-10-02 18:22:58 +09:00
|
|
|
} from "lucide-react";
|
2025-10-22 16:06:04 +09:00
|
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
2025-10-13 15:17:34 +09:00
|
|
|
import Link from "next/link";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-10-01 16:15:53 +09:00
|
|
|
import {
|
|
|
|
|
MailAccount,
|
|
|
|
|
MailTemplate,
|
|
|
|
|
getMailAccounts,
|
|
|
|
|
getMailTemplates,
|
|
|
|
|
sendMail,
|
|
|
|
|
extractTemplateVariables,
|
|
|
|
|
renderTemplateToHtml,
|
2025-10-22 16:06:04 +09:00
|
|
|
saveDraft,
|
|
|
|
|
updateDraft,
|
2025-10-01 16:15:53 +09:00
|
|
|
} from "@/lib/api/mail";
|
2025-11-28 11:34:48 +09:00
|
|
|
import { API_BASE_URL } from "@/lib/api/client";
|
2025-10-02 18:22:58 +09:00
|
|
|
import { useToast } from "@/hooks/use-toast";
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
export default function MailSendPage() {
|
2025-10-02 18:22:58 +09:00
|
|
|
const router = useRouter();
|
2025-10-22 16:06:04 +09:00
|
|
|
const searchParams = useSearchParams();
|
2025-10-02 18:22:58 +09:00
|
|
|
const { toast } = useToast();
|
2025-10-01 16:15:53 +09:00
|
|
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
|
|
|
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
2025-10-02 18:22:58 +09:00
|
|
|
const [sending, setSending] = useState(false);
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
// 폼 상태
|
|
|
|
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
|
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
2025-10-02 18:22:58 +09:00
|
|
|
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>("");
|
2025-10-01 16:15:53 +09:00
|
|
|
const [subject, setSubject] = useState<string>("");
|
2025-10-02 18:22:58 +09:00
|
|
|
const [customHtml, setCustomHtml] = useState<string>("");
|
2025-10-01 16:15:53 +09:00
|
|
|
const [variables, setVariables] = useState<Record<string, string>>({});
|
|
|
|
|
const [showPreview, setShowPreview] = useState(false);
|
2025-10-22 16:06:04 +09:00
|
|
|
const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
// 템플릿 변수
|
|
|
|
|
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
|
|
|
|
|
|
|
|
|
|
// 첨부파일
|
|
|
|
|
const [attachments, setAttachments] = useState<File[]>([]);
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
// 임시 저장
|
|
|
|
|
const [draftId, setDraftId] = useState<string | null>(null);
|
|
|
|
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
|
|
|
|
const [autoSaving, setAutoSaving] = useState(false);
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
loadData();
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
// 답장/전달 데이터 처리
|
|
|
|
|
const action = searchParams.get("action");
|
|
|
|
|
const dataParam = searchParams.get("data");
|
|
|
|
|
|
|
|
|
|
if (action && dataParam) {
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(decodeURIComponent(dataParam));
|
|
|
|
|
|
|
|
|
|
if (action === "reply") {
|
|
|
|
|
// 답장: 받는사람 자동 입력, 제목에 Re: 추가
|
|
|
|
|
const fromEmail = data.originalFrom.match(/<(.+?)>/)?.[1] || data.originalFrom;
|
|
|
|
|
setTo([fromEmail]);
|
|
|
|
|
setSubject(data.originalSubject.startsWith("Re: ")
|
|
|
|
|
? data.originalSubject
|
|
|
|
|
: `Re: ${data.originalSubject}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
|
|
|
|
|
const originalMessage = `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
원본 메일:
|
|
|
|
|
|
|
|
|
|
보낸사람: ${data.originalFrom}
|
|
|
|
|
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
|
|
|
|
|
제목: ${data.originalSubject}
|
|
|
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
|
|
|
|
|
${data.originalBody}`;
|
|
|
|
|
|
|
|
|
|
setCustomHtml(originalMessage);
|
|
|
|
|
|
|
|
|
|
toast({
|
|
|
|
|
title: '답장 작성',
|
|
|
|
|
description: '받는사람과 제목이 자동으로 입력되었습니다.',
|
|
|
|
|
});
|
|
|
|
|
} else if (action === "forward") {
|
|
|
|
|
// 전달: 받는사람 비어있음, 제목에 Fwd: 추가
|
|
|
|
|
setSubject(data.originalSubject.startsWith("Fwd: ")
|
|
|
|
|
? data.originalSubject
|
|
|
|
|
: `Fwd: ${data.originalSubject}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
|
|
|
|
|
const originalMessage = `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
전달된 메일:
|
|
|
|
|
|
|
|
|
|
보낸사람: ${data.originalFrom}
|
|
|
|
|
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
|
|
|
|
|
제목: ${data.originalSubject}
|
|
|
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
|
|
|
|
|
${data.originalBody}`;
|
|
|
|
|
|
|
|
|
|
setCustomHtml(originalMessage);
|
|
|
|
|
|
|
|
|
|
toast({
|
|
|
|
|
title: '메일 전달',
|
|
|
|
|
description: '전달할 메일 내용이 입력되었습니다. 받는사람을 입력하세요.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// URL에서 파라미터 제거 (깔끔하게)
|
|
|
|
|
router.replace("/admin/mail/send");
|
|
|
|
|
} catch (error) {
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.error("답장/전달 데이터 파싱 실패:", error);
|
2025-10-22 16:06:04 +09:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 임시 저장 메일 불러오기
|
|
|
|
|
const draftIdParam = searchParams.get("draftId");
|
|
|
|
|
const toParam = searchParams.get("to");
|
|
|
|
|
const ccParam = searchParams.get("cc");
|
|
|
|
|
const bccParam = searchParams.get("bcc");
|
|
|
|
|
const subjectParam = searchParams.get("subject");
|
|
|
|
|
const contentParam = searchParams.get("content");
|
|
|
|
|
const accountIdParam = searchParams.get("accountId");
|
|
|
|
|
|
|
|
|
|
if (draftIdParam) {
|
|
|
|
|
setDraftId(draftIdParam);
|
|
|
|
|
if (toParam) setTo(toParam.split(",").filter(Boolean));
|
|
|
|
|
if (ccParam) setCc(ccParam.split(",").filter(Boolean));
|
|
|
|
|
if (bccParam) setBcc(bccParam.split(",").filter(Boolean));
|
|
|
|
|
if (subjectParam) setSubject(subjectParam);
|
|
|
|
|
if (contentParam) setCustomHtml(contentParam);
|
|
|
|
|
if (accountIdParam) setSelectedAccountId(accountIdParam);
|
|
|
|
|
|
|
|
|
|
toast({
|
|
|
|
|
title: '임시 저장 메일 불러오기',
|
|
|
|
|
description: '작성 중이던 메일을 불러왔습니다.',
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}, [searchParams]);
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
const loadData = async () => {
|
|
|
|
|
try {
|
2025-10-02 18:22:58 +09:00
|
|
|
setLoading(true);
|
2025-10-01 16:15:53 +09:00
|
|
|
const [accountsData, templatesData] = await Promise.all([
|
|
|
|
|
getMailAccounts(),
|
|
|
|
|
getMailTemplates(),
|
|
|
|
|
]);
|
2025-10-22 16:06:04 +09:00
|
|
|
const activeAccounts = accountsData.filter((acc) => acc.status === "active");
|
|
|
|
|
setAccounts(activeAccounts);
|
2025-10-02 18:22:58 +09:00
|
|
|
setTemplates(templatesData);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
// 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택
|
|
|
|
|
if (!selectedAccountId && activeAccounts.length > 0) {
|
|
|
|
|
setSelectedAccountId(activeAccounts[0].id);
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email);
|
2025-10-22 16:06:04 +09:00
|
|
|
}
|
2025-10-22 17:07:38 +09:00
|
|
|
|
|
|
|
|
// console.log('📦 데이터 로드 완료:', {
|
|
|
|
|
// accounts: accountsData.length,
|
|
|
|
|
// templates: templatesData.length,
|
|
|
|
|
// templatesDetail: templatesData.map(t => ({
|
|
|
|
|
// id: t.id,
|
|
|
|
|
// name: t.name,
|
|
|
|
|
// componentsCount: t.components?.length || 0
|
|
|
|
|
// }))
|
|
|
|
|
// });
|
2025-10-02 18:22:58 +09:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const err = error as Error;
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.error('❌ 데이터 로드 실패:', err);
|
2025-10-02 18:22:58 +09:00
|
|
|
toast({
|
|
|
|
|
title: "데이터 로드 실패",
|
|
|
|
|
description: err.message,
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-10-01 16:15:53 +09:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
// 임시 저장 함수
|
|
|
|
|
const handleAutoSave = async () => {
|
|
|
|
|
if (!selectedAccountId || (!subject && !customHtml && to.length === 0)) {
|
|
|
|
|
return; // 저장할 내용이 없으면 스킵
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setAutoSaving(true);
|
|
|
|
|
|
|
|
|
|
const draftData = {
|
|
|
|
|
accountId: selectedAccountId,
|
|
|
|
|
accountName: accounts.find(a => a.id === selectedAccountId)?.name || "",
|
|
|
|
|
accountEmail: accounts.find(a => a.id === selectedAccountId)?.email || "",
|
|
|
|
|
to,
|
|
|
|
|
cc,
|
|
|
|
|
bcc,
|
|
|
|
|
subject,
|
|
|
|
|
htmlContent: customHtml,
|
|
|
|
|
templateId: selectedTemplateId || undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (draftId) {
|
|
|
|
|
// 기존 임시 저장 업데이트
|
|
|
|
|
await updateDraft(draftId, draftData);
|
|
|
|
|
} else {
|
|
|
|
|
// 새로운 임시 저장
|
|
|
|
|
const savedDraft = await saveDraft(draftData);
|
|
|
|
|
if (savedDraft && savedDraft.id) {
|
|
|
|
|
setDraftId(savedDraft.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setLastSaved(new Date());
|
|
|
|
|
} catch (error) {
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.error('임시 저장 실패:', error);
|
2025-10-22 16:06:04 +09:00
|
|
|
} finally {
|
|
|
|
|
setAutoSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 30초마다 자동 저장
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
handleAutoSave();
|
|
|
|
|
}, 30000); // 30초
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]);
|
|
|
|
|
|
2025-10-13 15:17:34 +09:00
|
|
|
// 템플릿 선택 시 (원본 다시 로드)
|
|
|
|
|
const handleTemplateChange = async (templateId: string) => {
|
|
|
|
|
console.log('🔄 템플릿 선택됨:', templateId);
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// "__custom__"는 직접 작성을 의미
|
|
|
|
|
if (templateId === "__custom__") {
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('✏️ 직접 작성 모드');
|
2025-10-02 18:22:58 +09:00
|
|
|
setSelectedTemplateId("");
|
|
|
|
|
setTemplateVariables([]);
|
|
|
|
|
setVariables({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 15:17:34 +09:00
|
|
|
try {
|
|
|
|
|
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('🔃 원본 템플릿 API에서 재로드 중...');
|
2025-10-13 15:17:34 +09:00
|
|
|
const freshTemplates = await getMailTemplates();
|
|
|
|
|
const template = freshTemplates.find((t) => t.id === templateId);
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('📋 찾은 템플릿:', {
|
|
|
|
|
// found: !!template,
|
|
|
|
|
// templateId,
|
|
|
|
|
// availableTemplates: freshTemplates.length,
|
|
|
|
|
// template: template ? {
|
|
|
|
|
// id: template.id,
|
|
|
|
|
// name: template.name,
|
|
|
|
|
// componentsCount: template.components?.length || 0
|
|
|
|
|
// } : null
|
|
|
|
|
// });
|
2025-10-13 15:17:34 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
|
|
|
|
|
// subject: template.subject,
|
|
|
|
|
// variables: vars
|
|
|
|
|
// });
|
2025-10-13 15:17:34 +09:00
|
|
|
} else {
|
|
|
|
|
setSelectedTemplateId("");
|
|
|
|
|
setTemplateVariables([]);
|
|
|
|
|
setVariables({});
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.warn('⚠️ 템플릿을 찾을 수 없음');
|
2025-10-13 15:17:34 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.error('❌ 템플릿 재로드 실패:', error);
|
2025-10-13 15:17:34 +09:00
|
|
|
toast({
|
|
|
|
|
title: "템플릿 로드 실패",
|
|
|
|
|
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
|
|
|
|
|
variant: "destructive",
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 이메일 태그 입력 처리 (쉼표, 엔터, 공백 시 추가)
|
|
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
2025-10-02 18:22:58 +09:00
|
|
|
};
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// 이메일 주소 유효성 검사
|
|
|
|
|
const isValidEmail = (email: string) => {
|
|
|
|
|
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
return regex.test(email);
|
2025-10-01 16:15:53 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// 이메일 태그 제거
|
|
|
|
|
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));
|
|
|
|
|
}
|
2025-10-01 16:15:53 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// 텍스트를 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 `
|
2025-10-22 16:06:04 +09:00
|
|
|
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
|
2025-10-02 18:22:58 +09:00
|
|
|
${html}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
2025-10-01 16:15:53 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// 메일 발송
|
|
|
|
|
const handleSendMail = async () => {
|
|
|
|
|
if (!selectedAccountId) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "계정 선택 필요",
|
|
|
|
|
description: "발송할 메일 계정을 선택해주세요.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-10-01 16:15:53 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
if (to.length === 0) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "수신자 필요",
|
|
|
|
|
description: "받는 사람을 1명 이상 입력해주세요.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-10-01 16:15:53 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!subject.trim()) {
|
2025-10-02 18:22:58 +09:00
|
|
|
toast({
|
|
|
|
|
title: "제목 필요",
|
|
|
|
|
description: "메일 제목을 입력해주세요.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-10-01 16:15:53 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
if (!selectedTemplateId && !customHtml.trim()) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "내용 필요",
|
|
|
|
|
description: "템플릿을 선택하거나 메일 내용을 입력해주세요.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-10-01 16:15:53 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-02 18:22:58 +09:00
|
|
|
setSending(true);
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
// HTML 변환
|
|
|
|
|
let htmlContent = undefined;
|
|
|
|
|
if (customHtml.trim()) {
|
|
|
|
|
// 일반 텍스트를 HTML로 변환
|
|
|
|
|
htmlContent = convertTextToHtml(customHtml);
|
|
|
|
|
}
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
// FormData 생성 (파일 첨부 지원)
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append("accountId", selectedAccountId);
|
|
|
|
|
if (selectedTemplateId) {
|
|
|
|
|
formData.append("templateId", selectedTemplateId);
|
2025-10-13 15:17:34 +09:00
|
|
|
|
|
|
|
|
// 🎯 수정된 템플릿 컴포넌트 전송
|
|
|
|
|
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
|
|
|
|
|
if (currentTemplate) {
|
|
|
|
|
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
|
2025-10-13 15:17:34 +09:00
|
|
|
}
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
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');
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
|
2025-10-02 18:22:58 +09:00
|
|
|
return normalizedName;
|
|
|
|
|
});
|
|
|
|
|
formData.append("fileNames", JSON.stringify(originalFileNames));
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API 호출 (FormData 전송)
|
|
|
|
|
const authToken = localStorage.getItem("authToken");
|
|
|
|
|
if (!authToken) {
|
|
|
|
|
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-28 11:34:48 +09:00
|
|
|
const response = await fetch(`${API_BASE_URL}/mail/send/simple`, {
|
2025-10-02 18:22:58 +09:00
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${authToken}`,
|
|
|
|
|
},
|
|
|
|
|
body: formData,
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
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",
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
// 알림 갱신 이벤트 발생
|
|
|
|
|
window.dispatchEvent(new CustomEvent('mail-sent'));
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// 폼 초기화
|
|
|
|
|
setTo([]);
|
|
|
|
|
setCc([]);
|
|
|
|
|
setBcc([]);
|
|
|
|
|
setToInput("");
|
|
|
|
|
setCcInput("");
|
|
|
|
|
setBccInput("");
|
|
|
|
|
setSubject("");
|
|
|
|
|
setCustomHtml("");
|
2025-10-01 16:15:53 +09:00
|
|
|
setVariables({});
|
2025-10-02 18:22:58 +09:00
|
|
|
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",
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
} finally {
|
2025-10-02 18:22:58 +09:00
|
|
|
setSending(false);
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
// 임시 저장
|
|
|
|
|
const handleSaveDraft = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setAutoSaving(true);
|
|
|
|
|
|
|
|
|
|
const account = accounts.find(a => a.id === selectedAccountId);
|
|
|
|
|
const draftData = {
|
|
|
|
|
accountId: selectedAccountId,
|
|
|
|
|
accountName: account?.name || "",
|
|
|
|
|
accountEmail: account?.email || "",
|
|
|
|
|
to,
|
|
|
|
|
cc,
|
|
|
|
|
bcc,
|
|
|
|
|
subject,
|
|
|
|
|
htmlContent: customHtml,
|
|
|
|
|
templateId: selectedTemplateId || undefined,
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('💾 임시 저장 데이터:', draftData);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
if (draftId) {
|
|
|
|
|
// 기존 임시 저장 업데이트
|
|
|
|
|
await updateDraft(draftId, draftData);
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('✏️ 임시 저장 업데이트 완료:', draftId);
|
2025-10-22 16:06:04 +09:00
|
|
|
} else {
|
|
|
|
|
// 새로운 임시 저장
|
|
|
|
|
const savedDraft = await saveDraft(draftData);
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('💾 임시 저장 완료:', savedDraft);
|
2025-10-22 16:06:04 +09:00
|
|
|
if (savedDraft && savedDraft.id) {
|
|
|
|
|
setDraftId(savedDraft.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setLastSaved(new Date());
|
|
|
|
|
|
|
|
|
|
toast({
|
|
|
|
|
title: "임시 저장 완료",
|
|
|
|
|
description: "작성 중인 메일이 저장되었습니다.",
|
|
|
|
|
});
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const err = error as Error;
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.error('❌ 임시 저장 실패:', err);
|
2025-10-22 16:06:04 +09:00
|
|
|
toast({
|
|
|
|
|
title: "임시 저장 실패",
|
|
|
|
|
description: err.message || "임시 저장 중 오류가 발생했습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setAutoSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// 파일 첨부 관련 함수
|
|
|
|
|
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 = () => {
|
2025-10-13 15:17:34 +09:00
|
|
|
console.log('👁️ 미리보기 토글:', {
|
|
|
|
|
current: showPreview,
|
|
|
|
|
willBe: !showPreview,
|
|
|
|
|
selectedTemplateId,
|
|
|
|
|
hasCustomHtml: !!customHtml
|
|
|
|
|
});
|
2025-10-02 18:22:58 +09:00
|
|
|
setShowPreview(!showPreview);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getPreviewHtml = () => {
|
|
|
|
|
if (selectedTemplateId) {
|
|
|
|
|
const template = templates.find((t) => t.id === selectedTemplateId);
|
|
|
|
|
if (template) {
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('🎨 템플릿 미리보기:', {
|
|
|
|
|
// templateId: selectedTemplateId,
|
|
|
|
|
// templateName: template.name,
|
|
|
|
|
// componentsCount: template.components?.length || 0,
|
|
|
|
|
// components: template.components,
|
|
|
|
|
// variables
|
|
|
|
|
// });
|
2025-10-13 15:17:34 +09:00
|
|
|
const html = renderTemplateToHtml(template, variables);
|
2025-10-22 17:07:38 +09:00
|
|
|
// console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
|
2025-10-13 15:17:34 +09:00
|
|
|
|
|
|
|
|
// 추가 메시지가 있으면 병합
|
|
|
|
|
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;
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 일반 텍스트를 HTML로 변환하여 미리보기
|
|
|
|
|
return customHtml ? convertTextToHtml(customHtml) : "";
|
|
|
|
|
};
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
2025-10-02 18:22:58 +09:00
|
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
|
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className="p-6 space-y-8 bg-background min-h-screen">
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 헤더 */}
|
2025-10-13 15:17:34 +09:00
|
|
|
<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 />
|
|
|
|
|
|
|
|
|
|
{/* 제목 */}
|
2025-10-22 16:06:04 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl font-bold text-foreground">
|
|
|
|
|
{subject.startsWith("Re: ") ? "답장 작성" : subject.startsWith("Fwd: ") ? "메일 전달" : "메일 발송"}
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="mt-2 text-muted-foreground">
|
|
|
|
|
{subject.startsWith("Re: ")
|
|
|
|
|
? "받은 메일에 답장을 작성합니다"
|
|
|
|
|
: subject.startsWith("Fwd: ")
|
|
|
|
|
? "메일을 다른 사람에게 전달합니다"
|
|
|
|
|
: "템플릿을 선택하거나 직접 작성하여 메일을 발송하세요"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
{/* 임시 저장 표시 */}
|
|
|
|
|
{lastSaved && (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
{autoSaving ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
<span>저장 중...</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
|
|
|
|
<span>
|
|
|
|
|
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
second: '2-digit'
|
|
|
|
|
})} 임시 저장됨
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 임시 저장 버튼 */}
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSaveDraft}
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={autoSaving}
|
|
|
|
|
>
|
|
|
|
|
{autoSaving ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
|
|
|
저장 중...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<FileText className="w-4 h-4 mr-1" />
|
|
|
|
|
임시 저장
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{/* 임시 저장 목록 버튼 */}
|
|
|
|
|
<Link href="/admin/mail/drafts">
|
|
|
|
|
<Button variant="outline" size="sm">
|
|
|
|
|
<Mail className="w-4 h-4 mr-1" />
|
|
|
|
|
임시 저장 목록
|
|
|
|
|
</Button>
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className={`grid gap-8 ${showPreview ? 'lg:grid-cols-3' : 'grid-cols-1'}`}>
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 메일 작성 폼 */}
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className={showPreview ? 'lg:col-span-2' : 'col-span-1'}>
|
|
|
|
|
<div className="space-y-8">
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 발송 설정 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Mail className="w-5 h-5" />
|
|
|
|
|
발송 설정
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
2025-10-13 15:17:34 +09:00
|
|
|
<CardContent className="space-y-6">
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 발송 계정 선택 */}
|
|
|
|
|
<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>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 템플릿 선택 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="template">템플릿 (선택)</Label>
|
|
|
|
|
<Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
|
|
|
|
|
<SelectTrigger id="template">
|
|
|
|
|
<SelectValue placeholder="템플릿을 선택하거나 직접 작성하세요" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__custom__">직접 작성</SelectItem>
|
2025-10-01 16:15:53 +09:00
|
|
|
{templates.map((template) => (
|
2025-10-02 18:22:58 +09:00
|
|
|
<SelectItem key={template.id} value={template.id}>
|
2025-10-01 16:15:53 +09:00
|
|
|
{template.name}
|
2025-10-02 18:22:58 +09:00
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 수신자 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Users className="w-5 h-5" />
|
|
|
|
|
수신자
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
2025-10-13 15:17:34 +09:00
|
|
|
<CardContent className="space-y-6">
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 받는 사람 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="to" className="flex items-center gap-2">
|
|
|
|
|
<Mail className="w-4 h-4" />
|
|
|
|
|
받는 사람 *
|
|
|
|
|
</Label>
|
|
|
|
|
<div className="space-y-2">
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
|
2025-10-02 18:22:58 +09:00
|
|
|
{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>
|
2025-10-01 16:15:53 +09:00
|
|
|
))}
|
2025-10-02 18:22:58 +09:00
|
|
|
<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>
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-02 18:22:58 +09:00
|
|
|
💡 이메일 주소를 입력하고 엔터, 쉼표(,), 스페이스를 눌러 추가하세요
|
|
|
|
|
</p>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 참조 (CC) */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="cc" className="flex items-center gap-2">
|
|
|
|
|
<UserPlus className="w-4 h-4" />
|
|
|
|
|
참조 (CC)
|
|
|
|
|
</Label>
|
|
|
|
|
<div className="space-y-2">
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
|
2025-10-02 18:22:58 +09:00
|
|
|
{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>
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-02 18:22:58 +09:00
|
|
|
다른 수신자에게도 공개됩니다
|
|
|
|
|
</p>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 숨은참조 (BCC) */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="bcc" className="flex items-center gap-2">
|
|
|
|
|
<EyeOff className="w-4 h-4" />
|
|
|
|
|
숨은참조 (BCC)
|
|
|
|
|
</Label>
|
|
|
|
|
<div className="space-y-2">
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
|
2025-10-02 18:22:58 +09:00
|
|
|
{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>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
|
|
|
|
))}
|
2025-10-02 18:22:58 +09:00
|
|
|
<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"
|
|
|
|
|
/>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-02 18:22:58 +09:00
|
|
|
🔒 다른 수신자에게 보이지 않습니다 (모니터링용)
|
|
|
|
|
</p>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 메일 내용 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<FileText className="w-5 h-5" />
|
|
|
|
|
메일 내용
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
2025-10-13 15:17:34 +09:00
|
|
|
<CardContent className="space-y-6">
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 제목 */}
|
|
|
|
|
<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}} 변수 값`}
|
|
|
|
|
/>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-13 15:17:34 +09:00
|
|
|
{/* 템플릿 편집 가능한 미리보기 */}
|
|
|
|
|
{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>
|
|
|
|
|
);
|
2025-11-28 11:34:48 +09:00
|
|
|
|
|
|
|
|
case 'header':
|
|
|
|
|
return (
|
|
|
|
|
<div key={component.id} className="p-4 rounded-lg" style={{ backgroundColor: component.headerBgColor || '#f8f9fa' }}>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
|
|
|
|
<span className="font-bold text-lg">{component.brandName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm text-gray-500">{component.sendDate}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'infoTable':
|
|
|
|
|
return (
|
|
|
|
|
<div key={component.id} className="border rounded-lg overflow-hidden">
|
|
|
|
|
{component.tableTitle && (
|
|
|
|
|
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
|
|
|
|
|
)}
|
|
|
|
|
<table className="w-full">
|
|
|
|
|
<tbody>
|
|
|
|
|
{component.rows?.map((row: any, i: number) => (
|
|
|
|
|
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
|
|
|
|
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
|
|
|
|
<td className="px-4 py-2">{row.value}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'alertBox':
|
|
|
|
|
return (
|
|
|
|
|
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
|
|
|
|
|
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
|
|
|
|
|
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
|
|
|
|
|
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
|
|
|
|
|
'bg-emerald-50 border-emerald-500 text-emerald-800'
|
|
|
|
|
}`}>
|
|
|
|
|
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
|
|
|
|
<div>{component.content}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'divider':
|
|
|
|
|
return (
|
|
|
|
|
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'footer':
|
|
|
|
|
return (
|
|
|
|
|
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
|
|
|
|
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
|
|
|
|
|
{(component.ceoName || component.businessNumber) && (
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
|
|
|
|
{component.ceoName && component.businessNumber && <span className="mx-2">|</span>}
|
|
|
|
|
{component.businessNumber && <span>사업자등록번호: {component.businessNumber}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{component.address && <div className="mt-1">{component.address}</div>}
|
|
|
|
|
{(component.phone || component.email) && (
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
{component.phone && <span>Tel: {component.phone}</span>}
|
|
|
|
|
{component.phone && component.email && <span className="mx-2">|</span>}
|
|
|
|
|
{component.email && <span>Email: {component.email}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'numberedList':
|
|
|
|
|
return (
|
|
|
|
|
<div key={component.id} className="p-4">
|
|
|
|
|
{component.listTitle && <div className="font-semibold mb-2">{component.listTitle}</div>}
|
|
|
|
|
<ol className="list-decimal list-inside space-y-1">
|
|
|
|
|
{component.listItems?.map((item: string, i: number) => (
|
|
|
|
|
<li key={i}>{item}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ol>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-10-13 15:17:34 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
})()}
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
{/* 메일 내용 입력 */}
|
|
|
|
|
{!showPreview && !selectedTemplateId && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="customHtml">내용 *</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="customHtml"
|
|
|
|
|
value={customHtml}
|
|
|
|
|
onChange={(e) => setCustomHtml(e.target.value)}
|
|
|
|
|
placeholder="메일 내용을 입력하세요 줄바꿈은 자동으로 처리됩니다."
|
|
|
|
|
rows={12}
|
|
|
|
|
className="resize-none"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 템플릿 선택 시 추가 메시지 */}
|
|
|
|
|
{!showPreview && selectedTemplateId && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="customHtml">추가 메시지 (선택)</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="customHtml"
|
|
|
|
|
value={customHtml}
|
|
|
|
|
onChange={(e) => setCustomHtml(e.target.value)}
|
|
|
|
|
placeholder="템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
|
|
|
|
|
rows={6}
|
|
|
|
|
className="resize-none"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
💡 입력한 내용은 템플릿 하단에 추가됩니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-02 18:22:58 +09:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 파일 첨부 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Paperclip className="w-5 h-5" />
|
|
|
|
|
파일 첨부
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
2025-10-13 15:17:34 +09:00
|
|
|
<CardContent className="space-y-6">
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 드래그 앤 드롭 영역 */}
|
|
|
|
|
<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"
|
2025-10-13 15:17:34 +09:00
|
|
|
: "border hover:border-primary/50"
|
2025-10-02 18:22:58 +09:00
|
|
|
}`}
|
|
|
|
|
onClick={() => document.getElementById("file-input")?.click()}
|
2025-10-01 16:15:53 +09:00
|
|
|
>
|
2025-10-02 18:22:58 +09:00
|
|
|
<input
|
|
|
|
|
id="file-input"
|
|
|
|
|
type="file"
|
|
|
|
|
multiple
|
|
|
|
|
onChange={handleFileSelect}
|
|
|
|
|
className="hidden"
|
|
|
|
|
/>
|
|
|
|
|
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-sm text-muted-foreground mb-1">
|
2025-10-02 18:22:58 +09:00
|
|
|
파일을 드래그하거나 클릭하여 선택하세요
|
|
|
|
|
</p>
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-02 18:22:58 +09:00
|
|
|
최대 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}
|
2025-10-13 15:17:34 +09:00
|
|
|
className="flex items-center justify-between p-3 bg-background rounded-lg border"
|
2025-10-02 18:22:58 +09:00
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
2025-10-13 15:17:34 +09:00
|
|
|
<File className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
2025-10-02 18:22:58 +09:00
|
|
|
<div className="flex-1 min-w-0">
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-sm font-medium text-foreground truncate">
|
2025-10-02 18:22:58 +09:00
|
|
|
{file.name}
|
|
|
|
|
</p>
|
2025-10-13 15:17:34 +09:00
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-02 18:22:58 +09:00
|
|
|
{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>
|
|
|
|
|
))}
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</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>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-13 15:17:34 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
{/* 미리보기 패널 */}
|
|
|
|
|
{showPreview && (
|
2025-10-01 16:15:53 +09:00
|
|
|
<div className="lg:col-span-1">
|
2025-10-02 18:22:58 +09:00
|
|
|
<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" />
|
|
|
|
|
미리보기
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-22 16:06:04 +09:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsEditingHtml(!isEditingHtml)}
|
|
|
|
|
>
|
|
|
|
|
{isEditingHtml ? <Eye className="w-4 h-4 mr-1" /> : <Settings className="w-4 h-4 mr-1" />}
|
|
|
|
|
{isEditingHtml ? "미리보기" : "편집"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setShowPreview(false)}
|
|
|
|
|
>
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
2025-10-13 15:17:34 +09:00
|
|
|
<div className="border rounded-lg p-4 bg-card overflow-auto max-h-[70vh]">
|
2025-10-02 18:22:58 +09:00
|
|
|
<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) => (
|
2025-10-13 15:17:34 +09:00
|
|
|
<div key={index} className="flex items-center gap-2 text-xs text-muted-foreground">
|
2025-10-02 18:22:58 +09:00
|
|
|
<File className="w-3 h-3" />
|
|
|
|
|
<span className="truncate">{file.name}</span>
|
|
|
|
|
<span className="text-gray-400">({formatFileSize(file.size)})</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-22 16:06:04 +09:00
|
|
|
{isEditingHtml ? (
|
|
|
|
|
<Textarea
|
|
|
|
|
value={customHtml}
|
|
|
|
|
onChange={(e) => setCustomHtml(e.target.value)}
|
|
|
|
|
rows={20}
|
|
|
|
|
className="font-mono text-xs"
|
|
|
|
|
placeholder="HTML 코드를 직접 편집할 수 있습니다"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
|
|
|
|
|
)}
|
2025-10-02 18:22:58 +09:00
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
2025-10-02 18:22:58 +09:00
|
|
|
)}
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|