ui 고치기 전 세이브
This commit is contained in:
481
frontend/app/(main)/admin/mail/bulk-send/page.tsx
Normal file
481
frontend/app/(main)/admin/mail/bulk-send/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
201
frontend/app/(main)/admin/mail/drafts/page.tsx
Normal file
201
frontend/app/(main)/admin/mail/drafts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="메일 내용을 입력하세요 줄바꿈은 자동으로 처리됩니다."
|
||||
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>
|
||||
|
||||
192
frontend/app/(main)/admin/mail/trash/page.tsx
Normal file
192
frontend/app/(main)/admin/mail/trash/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user