ui 고치기 전 세이브

This commit is contained in:
leeheejin
2025-10-22 16:06:04 +09:00
parent 79fef2691d
commit 479b0ba3ed
43 changed files with 3828 additions and 228 deletions

View File

@@ -0,0 +1,481 @@
"use client";
import React, { useState, useEffect } 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 {
Upload,
Send,
FileText,
Users,
AlertCircle,
CheckCircle2,
Loader2,
Download,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
import {
MailAccount,
MailTemplate,
getMailAccounts,
getMailTemplates,
sendBulkMail,
} from "@/lib/api/mail";
interface RecipientData {
email: string;
variables: Record<string, string>;
}
export default function BulkSendPage() {
const router = useRouter();
const { toast } = useToast();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [subject, setSubject] = useState<string>("");
const [recipients, setRecipients] = useState<RecipientData[]>([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [sendProgress, setSendProgress] = useState({ sent: 0, total: 0 });
useEffect(() => {
loadAccounts();
loadTemplates();
}, []);
const loadAccounts = async () => {
try {
const data = await getMailAccounts();
setAccounts(data.filter((acc) => acc.isActive));
} catch (error: unknown) {
const err = error as Error;
toast({
title: "계정 로드 실패",
description: err.message,
variant: "destructive",
});
}
};
const loadTemplates = async () => {
try {
const data = await getMailTemplates();
setTemplates(data);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "템플릿 로드 실패",
description: err.message,
variant: "destructive",
});
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".csv")) {
toast({
title: "파일 형식 오류",
description: "CSV 파일만 업로드 가능합니다.",
variant: "destructive",
});
return;
}
setCsvFile(file);
setLoading(true);
try {
const text = await file.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
throw new Error("CSV 파일에 데이터가 없습니다.");
}
// 첫 줄은 헤더
const headers = lines[0].split(",").map((h) => h.trim());
if (!headers.includes("email")) {
throw new Error("CSV 파일에 'email' 컬럼이 필요합니다.");
}
const emailIndex = headers.indexOf("email");
const variableHeaders = headers.filter((h) => h !== "email");
const parsedRecipients: RecipientData[] = lines.slice(1).map((line) => {
const values = line.split(",").map((v) => v.trim());
const email = values[emailIndex];
const variables: Record<string, string> = {};
variableHeaders.forEach((header, index) => {
const valueIndex = headers.indexOf(header);
variables[header] = values[valueIndex] || "";
});
return { email, variables };
});
setRecipients(parsedRecipients);
toast({
title: "파일 업로드 성공",
description: `${parsedRecipients.length}명의 수신자를 불러왔습니다.`,
});
} catch (error: unknown) {
const err = error as Error;
toast({
title: "파일 파싱 실패",
description: err.message,
variant: "destructive",
});
setCsvFile(null);
setRecipients([]);
} finally {
setLoading(false);
}
};
const handleSend = async () => {
if (!selectedAccountId) {
toast({
title: "계정 선택 필요",
description: "발송할 메일 계정을 선택해주세요.",
variant: "destructive",
});
return;
}
if (!selectedTemplateId) {
toast({
title: "템플릿 선택 필요",
description: "사용할 템플릿을 선택해주세요.",
variant: "destructive",
});
return;
}
if (!subject.trim()) {
toast({
title: "제목 입력 필요",
description: "메일 제목을 입력해주세요.",
variant: "destructive",
});
return;
}
if (recipients.length === 0) {
toast({
title: "수신자 없음",
description: "CSV 파일을 업로드해주세요.",
variant: "destructive",
});
return;
}
setSending(true);
setSendProgress({ sent: 0, total: recipients.length });
try {
await sendBulkMail({
accountId: selectedAccountId,
templateId: selectedTemplateId,
subject,
recipients,
onProgress: (sent, total) => {
setSendProgress({ sent, total });
},
});
toast({
title: "대량 발송 완료",
description: `${recipients.length}명에게 메일을 발송했습니다.`,
});
// 초기화
setSelectedAccountId("");
setSelectedTemplateId("");
setSubject("");
setRecipients([]);
setCsvFile(null);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "발송 실패",
description: err.message,
variant: "destructive",
});
} finally {
setSending(false);
}
};
const downloadSampleCsv = () => {
const sample = `email,name,company
example1@example.com,홍길동,ABC회사
example2@example.com,김철수,XYZ회사`;
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "sample.csv";
link.click();
};
return (
<div className="min-h-screen bg-background">
<div className="mx-auto w-full space-y-6 px-6 py-8">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-lg border bg-card p-8">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-primary/10 p-4">
<Users className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="mb-1 text-3xl font-bold text-foreground"> </h1>
<p className="text-muted-foreground">CSV </p>
</div>
</div>
<Link href="/admin/mail/dashboard">
<Button variant="outline" size="lg">
</Button>
</Link>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* 왼쪽: 설정 */}
<div className="space-y-6">
{/* 계정 선택 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<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={setSelectedTemplateId}>
<SelectTrigger id="template">
<SelectValue placeholder="템플릿 선택" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="subject"></Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="메일 제목을 입력하세요"
/>
</div>
</CardContent>
</Card>
{/* CSV 업로드 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="csv">CSV </Label>
<div className="mt-2 flex gap-2">
<Input
id="csv"
type="file"
accept=".csv"
onChange={handleFileUpload}
disabled={loading}
/>
<Button
variant="outline"
size="icon"
onClick={downloadSampleCsv}
title="샘플 다운로드"
>
<Download className="h-4 w-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
(email, name, company ) .
</p>
</div>
{csvFile && (
<div className="flex items-center justify-between rounded-md border bg-muted p-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{csvFile.name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCsvFile(null);
setRecipients([]);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
{recipients.length > 0 && (
<div className="rounded-md border bg-muted p-4">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="font-medium">{recipients.length} </span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
: {Object.keys(recipients[0]?.variables || {}).join(", ")}
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* 오른쪽: 미리보기 & 발송 */}
<div className="space-y-6">
{/* 발송 버튼 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{sending && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span> ...</span>
<span className="font-medium">
{sendProgress.sent} / {sendProgress.total}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300"
style={{
width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
}}
/>
</div>
</div>
)}
<Button
onClick={handleSend}
disabled={sending || recipients.length === 0}
className="w-full"
size="lg"
>
{sending ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
...
</>
) : (
<>
<Send className="mr-2 h-5 w-5" />
{recipients.length}
</>
)}
</Button>
<div className="rounded-md border bg-muted p-4">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="text-xs text-muted-foreground">
<p className="font-medium"></p>
<ul className="mt-1 list-inside list-disc space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 수신자 목록 미리보기 */}
{recipients.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-96 space-y-2 overflow-y-auto">
{recipients.slice(0, 10).map((recipient, index) => (
<div
key={index}
className="rounded-md border bg-muted p-3 text-sm"
>
<div className="font-medium">{recipient.email}</div>
<div className="mt-1 text-xs text-muted-foreground">
{Object.entries(recipient.variables).map(([key, value]) => (
<span key={key} className="mr-2">
{key}: {value}
</span>
))}
</div>
</div>
))}
{recipients.length > 10 && (
<p className="text-center text-xs text-muted-foreground">
{recipients.length - 10}
</p>
)}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -13,9 +13,12 @@ import {
TrendingUp,
Users,
Calendar,
ArrowRight
ArrowRight,
Trash2,
Edit
} from "lucide-react";
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
import MailNotifications from "@/components/mail/MailNotifications";
interface DashboardStats {
totalAccounts: number;
@@ -153,6 +156,13 @@ export default function MailDashboardPage() {
icon: Send,
color: "orange",
},
{
title: "대량 발송",
description: "CSV로 대량 발송",
href: "/admin/mail/bulk-send",
icon: Users,
color: "teal",
},
{
title: "보낸메일함",
description: "발송 이력 확인",
@@ -167,11 +177,25 @@ export default function MailDashboardPage() {
icon: Inbox,
color: "purple",
},
{
title: "임시 저장",
description: "작성 중인 메일",
href: "/admin/mail/drafts",
icon: Edit,
color: "amber",
},
{
title: "휴지통",
description: "삭제된 메일",
href: "/admin/mail/trash",
icon: Trash2,
color: "red",
},
];
return (
<div className="min-h-screen bg-background">
<div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6">
<div className="w-full px-3 py-3 space-y-3">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
<div className="flex items-center gap-4">
@@ -183,19 +207,22 @@ export default function MailDashboardPage() {
<p className="text-muted-foreground"> </p>
</div>
</div>
<Button
variant="outline"
size="lg"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<div className="flex gap-3">
<MailNotifications />
<Button
variant="outline"
size="lg"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{statCards.map((stat, index) => (
<Link key={index} href={stat.href}>
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
@@ -227,7 +254,7 @@ export default function MailDashboardPage() {
</div>
{/* 이번 달 통계 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card>
<CardHeader className="border-b">
<CardTitle className="text-lg flex items-center">

View File

@@ -0,0 +1,201 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getSentMailList, updateDraft, deleteSentMail, bulkDeleteMails, type SentMailHistory } from "@/lib/api/mail";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Edit, Trash2, Loader2, Mail } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
export default function DraftsPage() {
const router = useRouter();
const [drafts, setDrafts] = useState<SentMailHistory[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [bulkDeleting, setBulkDeleting] = useState(false);
useEffect(() => {
loadDrafts();
}, []);
const loadDrafts = async () => {
try {
setLoading(true);
const response = await getSentMailList({
status: "draft",
sortBy: "updatedAt",
sortOrder: "desc",
});
console.log('📋 임시 저장 목록 조회:', response);
console.log('📋 임시 저장 개수:', response.items.length);
setDrafts(response.items);
} catch (error) {
console.error("❌ 임시 저장 메일 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleEdit = (draft: SentMailHistory) => {
// 임시 저장 메일을 메일 발송 페이지로 전달
const params = new URLSearchParams({
draftId: draft.id,
to: draft.to.join(","),
cc: draft.cc?.join(",") || "",
bcc: draft.bcc?.join(",") || "",
subject: draft.subject,
content: draft.htmlContent,
accountId: draft.accountId,
});
router.push(`/admin/mail/send?${params.toString()}`);
};
const handleDelete = async (id: string) => {
if (!confirm("이 임시 저장 메일을 삭제하시겠습니까?")) return;
try {
setDeleting(id);
await deleteSentMail(id);
setDrafts(drafts.filter((d) => d.id !== id));
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
} catch (error) {
console.error("임시 저장 메일 삭제 실패:", error);
alert("삭제에 실패했습니다.");
} finally {
setDeleting(null);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) {
alert("삭제할 메일을 선택해주세요.");
return;
}
if (!confirm(`선택한 ${selectedIds.length}개의 임시 저장 메일을 삭제하시겠습니까?`)) return;
try {
setBulkDeleting(true);
const result = await bulkDeleteMails(selectedIds);
setDrafts(drafts.filter((d) => !selectedIds.includes(d.id)));
setSelectedIds([]);
alert(result.message);
} catch (error) {
console.error("일괄 삭제 실패:", error);
alert("일괄 삭제에 실패했습니다.");
} finally {
setBulkDeleting(false);
}
};
const handleSelectAll = () => {
if (selectedIds.length === drafts.length) {
setSelectedIds([]);
} else {
setSelectedIds(drafts.map((d) => d.id));
}
};
const handleSelectOne = (id: string) => {
if (selectedIds.includes(id)) {
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
} else {
setSelectedIds([...selectedIds, id]);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-3 space-y-3">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> </p>
</div>
{drafts.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground"> </p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{drafts.map((draft) => (
<Card key={draft.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">
{draft.subject || "(제목 없음)"}
</CardTitle>
<CardDescription className="mt-1">
: {draft.to.join(", ") || "(없음)"}
</CardDescription>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(draft)}
className="h-8"
>
<Edit className="w-4 h-4 mr-1" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(draft.id)}
disabled={deleting === draft.id}
className="h-8"
>
{deleting === draft.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Trash2 className="w-4 h-4 mr-1" />
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>: {draft.accountName || draft.accountEmail}</span>
<span>
{draft.updatedAt
? format(new Date(draft.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko })
: format(new Date(draft.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
</span>
</div>
{draft.htmlContent && (
<div
className="mt-2 text-sm text-muted-foreground line-clamp-2"
dangerouslySetInnerHTML={{
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
}}
/>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -16,21 +16,30 @@ import {
SortAsc,
SortDesc,
ChevronRight,
Reply,
Forward,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
MailAccount,
ReceivedMail,
MailDetail,
getMailAccounts,
getReceivedMails,
testImapConnection,
getMailDetail,
markMailAsRead,
downloadMailAttachment,
} from "@/lib/api/mail";
import MailDetailModal from "@/components/mail/MailDetailModal";
import { apiClient } from "@/lib/api/client";
import DOMPurify from "isomorphic-dompurify";
export default function MailReceivePage() {
const router = useRouter();
const searchParams = useSearchParams();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [mails, setMails] = useState<ReceivedMail[]>([]);
@@ -41,9 +50,11 @@ export default function MailReceivePage() {
message: string;
} | null>(null);
// 메일 상세 모달 상태
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
// 메일 상세 상태 (모달 대신 패널)
const [selectedMailId, setSelectedMailId] = useState<string>("");
const [selectedMailDetail, setSelectedMailDetail] = useState<MailDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [deleting, setDeleting] = useState(false);
// 검색 및 필터 상태
const [searchTerm, setSearchTerm] = useState<string>("");
@@ -62,6 +73,30 @@ export default function MailReceivePage() {
}
}, [selectedAccountId]);
// URL 파라미터에서 mailId 읽기 및 자동 선택
useEffect(() => {
const mailId = searchParams.get('mailId');
const accountId = searchParams.get('accountId');
if (mailId && accountId) {
console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
setSelectedAccountId(accountId);
setSelectedMailId(mailId);
// 메일 상세 로드는 handleMailClick에서 처리됨
}
}, [searchParams]);
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
useEffect(() => {
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
const mail = mails.find(m => m.id === selectedMailId);
if (mail) {
console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
handleMailClick(mail);
}
}
}, [mails, selectedMailId, selectedMailDetail]); // selectedMailDetail 추가로 무한 루프 방지
// 자동 새로고침 (30초마다)
useEffect(() => {
if (!selectedAccountId) return;
@@ -95,7 +130,22 @@ export default function MailReceivePage() {
setTestResult(null);
try {
const data = await getReceivedMails(selectedAccountId, 50);
setMails(data);
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
setMails((prevMails) => {
const localReadMailIds = new Set(
prevMails.filter(m => m.isRead).map(m => m.id)
);
return data.map(mail => ({
...mail,
// 로컬에서 읽음 처리했거나 서버에서 읽음 상태면 읽음으로 표시
isRead: mail.isRead || localReadMailIds.has(mail.id)
}));
});
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
window.dispatchEvent(new CustomEvent('mail-received'));
} catch (error) {
console.error("메일 로드 실패:", error);
alert(
@@ -153,14 +203,94 @@ export default function MailReceivePage() {
}
};
const handleMailClick = (mail: ReceivedMail) => {
const handleMailClick = async (mail: ReceivedMail) => {
setSelectedMailId(mail.id);
setIsDetailModalOpen(true);
setLoadingDetail(true);
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
setMails((prevMails) =>
prevMails.map((m) =>
m.id === mail.id ? { ...m, isRead: true } : m
)
);
// 메일 상세 정보 로드
try {
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
const mailIdParts = mail.id.split('-');
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
const seqno = parseInt(mailIdParts[2], 10); // 13
console.log('🔍 추출된 accountId:', accountId, 'seqno:', seqno, '원본 mailId:', mail.id);
const detail = await getMailDetail(accountId, seqno);
setSelectedMailDetail(detail);
// 읽음 처리
if (!mail.isRead) {
await markMailAsRead(accountId, seqno);
console.log('✅ 읽음 처리 완료 - seqno:', seqno);
// 서버 상태 동기화 (백그라운드) - IMAP 서버 반영 대기
setTimeout(() => {
if (selectedAccountId) {
console.log('🔄 서버 상태 동기화 시작');
loadMails();
}
}, 2000); // 2초로 증가
}
} catch (error) {
console.error('메일 상세 로드 실패:', error);
} finally {
setLoadingDetail(false);
}
};
const handleMailRead = () => {
// 메일을 읽었으므로 목록 새로고침
loadMails();
const handleDeleteMail = async () => {
if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠ IMAP 연결에 시간이 걸릴 수 있습니다.")) return;
try {
setDeleting(true);
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
const mailIdParts = selectedMailId.split('-');
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
const seqno = parseInt(mailIdParts[2], 10); // 10
console.log(`🗑️ 메일 삭제 시도: accountId=${accountId}, seqno=${seqno}`);
// IMAP 서버에서 메일 삭제 (타임아웃 40초)
const response = await apiClient.delete(`/mail/receive/${accountId}/${seqno}`, {
timeout: 40000, // 40초 타임아웃
});
if (response.data.success) {
// 메일 목록에서 제거
setMails(mails.filter((m) => m.id !== selectedMailId));
// 상세 패널 닫기
setSelectedMailId("");
setSelectedMailDetail(null);
alert("메일이 삭제되었습니다.");
console.log("✅ 메일 삭제 완료");
}
} catch (error: any) {
console.error("메일 삭제 실패:", error);
let errorMessage = "메일 삭제에 실패했습니다.";
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message;
}
alert(errorMessage);
} finally {
setDeleting(false);
}
};
// 필터링 및 정렬된 메일 목록
@@ -365,106 +495,318 @@ export default function MailReceivePage() {
</Card>
)}
{/* 메일 목록 */}
{loading ? (
<Card className="">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-muted-foreground"> ...</span>
</CardContent>
</Card>
) : filteredAndSortedMails.length === 0 ? (
<Card className="text-center py-16 bg-card ">
<CardContent className="pt-6">
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-muted-foreground mb-4">
{!selectedAccountId
? "메일 계정을 선택하세요"
: searchTerm || filterStatus !== "all"
? "검색 결과가 없습니다"
: "받은 메일이 없습니다"}
</p>
{selectedAccountId && (
<Button
onClick={handleTestConnection}
variant="outline"
disabled={testing}
>
{testing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
{/* 네이버 메일 스타일 3-column 레이아웃 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 왼쪽: 메일 목록 */}
<div className="lg:col-span-1">
{loading ? (
<Card className="">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-muted-foreground"> ...</span>
</CardContent>
</Card>
) : filteredAndSortedMails.length === 0 ? (
<Card className="text-center py-16 bg-card ">
<CardContent className="pt-6">
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-muted-foreground mb-4">
{!selectedAccountId
? "메일 계정을 선택하세요"
: searchTerm || filterStatus !== "all"
? "검색 결과가 없습니다"
: "받은 메일이 없습니다"}
</p>
{selectedAccountId && (
<Button
onClick={handleTestConnection}
variant="outline"
disabled={testing}
>
{testing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
IMAP
</Button>
)}
IMAP
</Button>
)}
</CardContent>
</Card>
) : (
<Card className="">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
<CardTitle className="flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-500" />
({filteredAndSortedMails.length}/{mails.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{filteredAndSortedMails.map((mail) => (
<div
key={mail.id}
onClick={() => handleMailClick(mail)}
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
!mail.isRead ? "bg-blue-50/30" : ""
}`}
>
<div className="flex items-start gap-4">
{/* 읽음 표시 */}
<div className="flex-shrink-0 w-2 h-2 mt-2">
{!mail.isRead && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
{/* 메일 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span
className={`text-sm ${
mail.isRead
? "text-muted-foreground"
: "text-foreground font-semibold"
}`}
>
{mail.from}
</span>
<div className="flex items-center gap-2">
{mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" />
</CardContent>
</Card>
) : (
<Card className="">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
<CardTitle className="flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-500" />
({filteredAndSortedMails.length}/{mails.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto">
{filteredAndSortedMails.map((mail) => (
<div
key={mail.id}
onClick={() => handleMailClick(mail)}
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
!mail.isRead ? "bg-blue-50/30" : ""
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
>
<div className="flex items-start gap-4">
{/* 읽음 표시 */}
<div className="flex-shrink-0 w-2 h-2 mt-2">
{!mail.isRead && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
<span className="text-xs text-muted-foreground">
{formatDate(mail.date)}
</span>
</div>
{/* 메일 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span
className={`text-sm ${
mail.isRead
? "text-muted-foreground"
: "text-foreground font-semibold"
}`}
>
{mail.from}
</span>
<div className="flex items-center gap-2">
{mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" />
)}
<span className="text-xs text-muted-foreground">
{formatDate(mail.date)}
</span>
</div>
</div>
<h3
className={`text-sm mb-1 truncate ${
mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`}
>
{mail.subject}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{mail.preview}
</p>
</div>
</div>
<h3
className={`text-sm mb-1 truncate ${
mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`}
>
{mail.subject}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{mail.preview}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* 오른쪽: 메일 상세 패널 */}
<div className="lg:col-span-1">
{selectedMailDetail ? (
<Card className="sticky top-6">
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{selectedMailDetail.subject}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedMailId("");
setSelectedMailDetail(null);
}}
>
</Button>
</div>
<div className="text-sm text-muted-foreground space-y-1 mt-2">
<div className="flex items-center gap-2">
<span className="font-medium"> :</span>
<span>{selectedMailDetail.from}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium"> :</span>
<span>{selectedMailDetail.to}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">:</span>
<span>{new Date(selectedMailDetail.date).toLocaleString("ko-KR")}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 답장/전달/삭제 버튼 */}
<div className="flex gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => {
// HTML 태그 제거 함수 (강력한 버전)
const stripHtml = (html: string) => {
if (!html) return "";
// 1. DOMPurify로 먼저 정제
const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [], // 모든 태그 제거
KEEP_CONTENT: true // 내용만 유지
});
// 2. DOM으로 텍스트만 추출
const tmp = document.createElement("DIV");
tmp.innerHTML = cleanHtml;
let text = tmp.textContent || tmp.innerText || "";
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
// 4. 연속된 공백 정리
text = text.replace(/\s+/g, ' ').trim();
return text;
};
console.log('📧 답장 데이터:', {
htmlBody: selectedMailDetail.htmlBody,
textBody: selectedMailDetail.textBody,
});
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
const bodyText = selectedMailDetail.textBody
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
console.log('📧 변환된 본문:', bodyText);
const replyData = {
originalFrom: selectedMailDetail.from,
originalSubject: selectedMailDetail.subject,
originalDate: selectedMailDetail.date,
originalBody: bodyText,
};
router.push(
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
);
}}
>
<Reply className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// HTML 태그 제거 함수 (강력한 버전)
const stripHtml = (html: string) => {
if (!html) return "";
// 1. DOMPurify로 먼저 정제
const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [], // 모든 태그 제거
KEEP_CONTENT: true // 내용만 유지
});
// 2. DOM으로 텍스트만 추출
const tmp = document.createElement("DIV");
tmp.innerHTML = cleanHtml;
let text = tmp.textContent || tmp.innerText || "";
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
// 4. 연속된 공백 정리
text = text.replace(/\s+/g, ' ').trim();
return text;
};
console.log('📧 전달 데이터:', {
htmlBody: selectedMailDetail.htmlBody,
textBody: selectedMailDetail.textBody,
});
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
const bodyText = selectedMailDetail.textBody
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
console.log('📧 변환된 본문:', bodyText);
const forwardData = {
originalFrom: selectedMailDetail.from,
originalSubject: selectedMailDetail.subject,
originalDate: selectedMailDetail.date,
originalBody: bodyText,
};
router.push(
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
);
}}
>
<Forward className="w-4 h-4 mr-1" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteMail}
disabled={deleting}
>
{deleting ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-1" />
)}
</Button>
</div>
</CardHeader>
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto">
{/* 첨부파일 */}
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-lg">
<p className="text-sm font-medium mb-2"> ({selectedMailDetail.attachments.length})</p>
<div className="space-y-1">
{selectedMailDetail.attachments.map((att, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<Paperclip className="w-4 h-4" />
<span>{att.filename}</span>
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
</div>
))}
</div>
</div>
)}
{/* 메일 본문 */}
{selectedMailDetail.htmlBody ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(selectedMailDetail.htmlBody),
}}
/>
) : (
<div className="whitespace-pre-wrap text-sm">
{selectedMailDetail.textBody}
</div>
)}
</CardContent>
</Card>
) : loadingDetail ? (
<Card className="sticky top-6">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-muted-foreground"> ...</span>
</CardContent>
</Card>
) : (
<Card className="sticky top-6">
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
<Mail className="w-16 h-16 mb-4 text-gray-300" />
<p className="text-muted-foreground">
</p>
</CardContent>
</Card>
)}
</div>
</div>
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
@@ -563,15 +905,6 @@ export default function MailReceivePage() {
</CardContent>
</Card>
</div>
{/* 메일 상세 모달 */}
<MailDetailModal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
accountId={selectedAccountId}
mailId={selectedMailId}
onMailRead={handleMailRead}
/>
</div>
);
}

View File

@@ -31,7 +31,7 @@ import {
Settings,
ChevronRight,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
@@ -42,11 +42,14 @@ import {
sendMail,
extractTemplateVariables,
renderTemplateToHtml,
saveDraft,
updateDraft,
} from "@/lib/api/mail";
import { useToast } from "@/hooks/use-toast";
export default function MailSendPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [templates, setTemplates] = useState<MailTemplate[]>([]);
@@ -66,6 +69,7 @@ export default function MailSendPage() {
const [customHtml, setCustomHtml] = useState<string>("");
const [variables, setVariables] = useState<Record<string, string>>({});
const [showPreview, setShowPreview] = useState(false);
const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드
// 템플릿 변수
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
@@ -74,9 +78,113 @@ export default function MailSendPage() {
const [attachments, setAttachments] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
// 임시 저장
const [draftId, setDraftId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [autoSaving, setAutoSaving] = useState(false);
useEffect(() => {
loadData();
}, []);
// 답장/전달 데이터 처리
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) {
console.error("답장/전달 데이터 파싱 실패:", error);
}
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]);
const loadData = async () => {
try {
@@ -85,8 +193,16 @@ export default function MailSendPage() {
getMailAccounts(),
getMailTemplates(),
]);
setAccounts(accountsData.filter((acc) => acc.status === "active"));
const activeAccounts = accountsData.filter((acc) => acc.status === "active");
setAccounts(activeAccounts);
setTemplates(templatesData);
// 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택
if (!selectedAccountId && activeAccounts.length > 0) {
setSelectedAccountId(activeAccounts[0].id);
console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email);
}
console.log('📦 데이터 로드 완료:', {
accounts: accountsData.length,
templates: templatesData.length,
@@ -109,6 +225,55 @@ export default function MailSendPage() {
}
};
// 임시 저장 함수
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) {
console.error('임시 저장 실패:', error);
} finally {
setAutoSaving(false);
}
};
// 30초마다 자동 저장
useEffect(() => {
const interval = setInterval(() => {
handleAutoSave();
}, 30000); // 30초
return () => clearInterval(interval);
}, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]);
// 템플릿 선택 시 (원본 다시 로드)
const handleTemplateChange = async (templateId: string) => {
console.log('🔄 템플릿 선택됨:', templateId);
@@ -228,7 +393,7 @@ export default function MailSendPage() {
.join('');
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
${html}
</div>
`;
@@ -275,8 +440,12 @@ export default function MailSendPage() {
try {
setSending(true);
// 텍스트를 HTML로 자동 변환
const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined;
// HTML 변환
let htmlContent = undefined;
if (customHtml.trim()) {
// 일반 텍스트를 HTML로 변환
htmlContent = convertTextToHtml(customHtml);
}
// FormData 생성 (파일 첨부 지원)
const formData = new FormData();
@@ -354,6 +523,9 @@ export default function MailSendPage() {
className: "border-green-500 bg-green-50",
});
// 알림 갱신 이벤트 발생
window.dispatchEvent(new CustomEvent('mail-sent'));
// 폼 초기화
setTo([]);
setCc([]);
@@ -383,6 +555,58 @@ export default function MailSendPage() {
}
};
// 임시 저장
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,
};
console.log('💾 임시 저장 데이터:', draftData);
if (draftId) {
// 기존 임시 저장 업데이트
await updateDraft(draftId, draftData);
console.log('✏️ 임시 저장 업데이트 완료:', draftId);
} else {
// 새로운 임시 저장
const savedDraft = await saveDraft(draftData);
console.log('💾 임시 저장 완료:', savedDraft);
if (savedDraft && savedDraft.id) {
setDraftId(savedDraft.id);
}
}
setLastSaved(new Date());
toast({
title: "임시 저장 완료",
description: "작성 중인 메일이 저장되었습니다.",
});
} catch (error: unknown) {
const err = error as Error;
console.error('❌ 임시 저장 실패:', err);
toast({
title: "임시 저장 실패",
description: err.message || "임시 저장 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setAutoSaving(false);
}
};
// 파일 첨부 관련 함수
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
@@ -531,9 +755,72 @@ export default function MailSendPage() {
<Separator />
{/* 제목 */}
<div>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">릿 </p>
<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>
</div>
</div>
@@ -957,30 +1244,41 @@ export default function MailSendPage() {
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>
{/* 메일 내용 입력 */}
{!showPreview && !selectedTemplateId && (
<div>
<Label htmlFor="customHtml"> *</Label>
<Textarea
id="customHtml"
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
placeholder="메일 내용을 입력하세요&#10;&#10;줄바꿈은 자동으로 처리됩니다."
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>
)}
</CardContent>
</Card>
@@ -1100,13 +1398,23 @@ export default function MailSendPage() {
<Eye className="w-5 h-5" />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(false)}
>
<X className="w-4 h-4" />
</Button>
<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>
</CardTitle>
</CardHeader>
<CardContent>
@@ -1143,7 +1451,17 @@ export default function MailSendPage() {
</div>
)}
</div>
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
{isEditingHtml ? (
<Textarea
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
rows={20}
className="font-mono text-xs"
placeholder="HTML 코드를 직접 편집할 수 있습니다"
/>
) : (
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
)}
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,192 @@
"use client";
import { useState, useEffect } from "react";
import { getSentMailList, restoreMail, permanentlyDeleteMail, type SentMailHistory } from "@/lib/api/mail";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { RotateCcw, Trash2, Loader2, Mail, AlertCircle } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
export default function TrashPage() {
const [trashedMails, setTrashedMails] = useState<SentMailHistory[]>([]);
const [loading, setLoading] = useState(true);
const [restoring, setRestoring] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
useEffect(() => {
loadTrashedMails();
}, []);
const loadTrashedMails = async () => {
try {
setLoading(true);
const response = await getSentMailList({
onlyDeleted: true,
sortBy: "sentAt",
sortOrder: "desc",
});
setTrashedMails(response.items);
} catch (error) {
console.error("휴지통 메일 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleRestore = async (id: string) => {
try {
setRestoring(id);
await restoreMail(id);
setTrashedMails(trashedMails.filter((m) => m.id !== id));
} catch (error) {
console.error("메일 복구 실패:", error);
alert("복구에 실패했습니다.");
} finally {
setRestoring(null);
}
};
const handlePermanentDelete = async (id: string) => {
if (!confirm("이 메일을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.")) return;
try {
setDeleting(id);
await permanentlyDeleteMail(id);
setTrashedMails(trashedMails.filter((m) => m.id !== id));
} catch (error) {
console.error("메일 영구 삭제 실패:", error);
alert("삭제에 실패했습니다.");
} finally {
setDeleting(null);
}
};
const handleEmptyTrash = async () => {
if (!confirm(`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
try {
setLoading(true);
await Promise.all(trashedMails.map((mail) => permanentlyDeleteMail(mail.id)));
setTrashedMails([]);
alert("휴지통을 비웠습니다.");
} catch (error) {
console.error("휴지통 비우기 실패:", error);
alert("일부 메일 삭제에 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> 30 </p>
</div>
{trashedMails.length > 0 && (
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
</div>
{trashedMails.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground"> </p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{trashedMails.map((mail) => {
const deletedDate = mail.deletedAt ? new Date(mail.deletedAt) : null;
const daysLeft = deletedDate
? Math.max(0, 30 - Math.floor((Date.now() - deletedDate.getTime()) / (1000 * 60 * 60 * 24)))
: 30;
return (
<Card key={mail.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">
{mail.subject || "(제목 없음)"}
</CardTitle>
<CardDescription className="mt-1">
: {mail.to.join(", ") || "(없음)"}
</CardDescription>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(mail.id)}
disabled={restoring === mail.id}
className="h-8"
>
{restoring === mail.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<RotateCcw className="w-4 h-4 mr-1" />
</>
)}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePermanentDelete(mail.id)}
disabled={deleting === mail.id}
className="h-8"
>
{deleting === mail.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Trash2 className="w-4 h-4 mr-1" />
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
: {mail.accountName || mail.accountEmail}
</span>
<span className="text-muted-foreground">
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
</span>
</div>
{daysLeft <= 7 && (
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600">
<AlertCircle className="w-3 h-3" />
<span>{daysLeft} </span>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}