커밋 메세지 메뉴별 대중소 정리
This commit is contained in:
246
frontend/app/(main)/admin/automaticMng/mail/accounts/page.tsx
Normal file
246
frontend/app/(main)/admin/automaticMng/mail/accounts/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mail, Plus, Loader2, RefreshCw, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
MailAccount,
|
||||
getMailAccounts,
|
||||
createMailAccount,
|
||||
updateMailAccount,
|
||||
deleteMailAccount,
|
||||
testMailAccountConnection,
|
||||
CreateMailAccountDto,
|
||||
UpdateMailAccountDto,
|
||||
} from "@/lib/api/mail";
|
||||
import MailAccountModal from "@/components/mail/MailAccountModal";
|
||||
import MailAccountTable from "@/components/mail/MailAccountTable";
|
||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailAccountsPage() {
|
||||
const router = useRouter();
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMailAccounts();
|
||||
// 배열인지 확인하고 설정
|
||||
if (Array.isArray(data)) {
|
||||
setAccounts(data);
|
||||
} else {
|
||||
// console.error('API 응답이 배열이 아닙니다:', data);
|
||||
setAccounts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('계정 로드 실패:', error);
|
||||
setAccounts([]); // 에러 시 빈 배열로 설정
|
||||
// alert('계정 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setModalMode('create');
|
||||
setSelectedAccount(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (account: MailAccount) => {
|
||||
setModalMode('edit');
|
||||
setSelectedAccount(account);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (account: MailAccount) => {
|
||||
setSelectedAccount(account);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createMailAccount(data as CreateMailAccountDto);
|
||||
} else if (modalMode === 'edit' && selectedAccount) {
|
||||
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
|
||||
}
|
||||
await loadAccounts();
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
throw error; // 모달에서 에러 처리
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!selectedAccount) return;
|
||||
|
||||
try {
|
||||
await deleteMailAccount(selectedAccount.id);
|
||||
await loadAccounts();
|
||||
alert('계정이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
// console.error('계정 삭제 실패:', error);
|
||||
alert('계정 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (account: MailAccount) => {
|
||||
try {
|
||||
const newStatus = account.status === 'active' ? 'inactive' : 'active';
|
||||
await updateMailAccount(account.id, { status: newStatus });
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
// console.error('상태 변경 실패:', error);
|
||||
alert('상태 변경에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (account: MailAccount) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await testMailAccountConnection(account.id);
|
||||
|
||||
if (result.success) {
|
||||
alert(`✅ SMTP 연결 성공!\n\n${result.message || '정상적으로 연결되었습니다.'}`);
|
||||
} else {
|
||||
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// console.error('연결 테스트 실패:', error);
|
||||
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">계정 관리</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 + 액션 버튼들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">메일 계정 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAccounts}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 계정 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<MailAccountTable
|
||||
accounts={accounts}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onTestConnection={handleTestConnection}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Mail className="w-5 h-5 mr-2 text-foreground" />
|
||||
메일 계정 관리
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground mb-4">
|
||||
💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>Gmail, Naver, 자체 SMTP 서버 지원</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>비밀번호는 암호화되어 안전하게 저장됩니다</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>일일 발송 제한 설정 가능</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 모달들 */}
|
||||
<MailAccountModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveAccount}
|
||||
account={selectedAccount}
|
||||
mode={modalMode}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteAccount}
|
||||
title="메일 계정 삭제"
|
||||
message="이 메일 계정을 삭제하시겠습니까?"
|
||||
itemName={selectedAccount?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
524
frontend/app/(main)/admin/automaticMng/mail/bulk-send/page.tsx
Normal file
524
frontend/app/(main)/admin/automaticMng/mail/bulk-send/page.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
"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 [useTemplate, setUseTemplate] = useState<boolean>(true); // 템플릿 사용 여부
|
||||
const [customHtml, setCustomHtml] = useState<string>(""); // 직접 작성한 HTML
|
||||
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.status === 'active'));
|
||||
} 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 (useTemplate && !selectedTemplateId) {
|
||||
toast({
|
||||
title: "템플릿 선택 필요",
|
||||
description: "사용할 템플릿을 선택해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!useTemplate && !customHtml.trim()) {
|
||||
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: useTemplate ? selectedTemplateId : undefined,
|
||||
customHtml: !useTemplate ? customHtml : undefined,
|
||||
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="mode">발송 방식</Label>
|
||||
<Select value={useTemplate ? "template" : "custom"} onValueChange={(v) => setUseTemplate(v === "template")}>
|
||||
<SelectTrigger id="mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="template">템플릿 사용</SelectItem>
|
||||
<SelectItem value="custom">직접 작성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{useTemplate ? (
|
||||
<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="customHtml">메일 내용</Label>
|
||||
<Textarea
|
||||
id="customHtml"
|
||||
value={customHtml}
|
||||
onChange={(e) => setCustomHtml(e.target.value)}
|
||||
placeholder="메일 내용을 작성하세요..."
|
||||
rows={10}
|
||||
className="text-sm"
|
||||
/>
|
||||
{/* <p className="mt-1 text-xs text-muted-foreground">
|
||||
HTML 태그를 사용할 수 있습니다
|
||||
</p> */}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
Inbox,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Calendar,
|
||||
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;
|
||||
totalTemplates: number;
|
||||
sentToday: number;
|
||||
receivedToday: number;
|
||||
sentThisMonth: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function MailDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalAccounts: 0,
|
||||
totalTemplates: 0,
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
sentThisMonth: 0,
|
||||
successRate: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accounts = await getMailAccounts();
|
||||
const templates = await getMailTemplates();
|
||||
|
||||
// 메일 통계 조회 (실패 시 기본값 사용)
|
||||
let mailStats = {
|
||||
todayCount: 0,
|
||||
thisMonthCount: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const stats = await getMailStatistics();
|
||||
if (stats && typeof stats === 'object') {
|
||||
mailStats = {
|
||||
todayCount: stats.todayCount || 0,
|
||||
thisMonthCount: stats.thisMonthCount || 0,
|
||||
successRate: stats.successRate || 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('메일 통계 조회 실패:', error);
|
||||
// 기본값 사용
|
||||
}
|
||||
|
||||
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
|
||||
let receivedTodayCount = 0;
|
||||
try {
|
||||
receivedTodayCount = await getTodayReceivedCount();
|
||||
} catch (error) {
|
||||
// console.error('수신 메일 수 조회 실패:', error);
|
||||
// 실패 시 0으로 표시
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalAccounts: accounts.length,
|
||||
totalTemplates: templates.length,
|
||||
sentToday: mailStats.todayCount,
|
||||
receivedToday: receivedTodayCount,
|
||||
sentThisMonth: mailStats.thisMonthCount,
|
||||
successRate: mailStats.successRate,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('통계 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "등록된 계정",
|
||||
value: stats.totalAccounts,
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
href: "/admin/mail/accounts",
|
||||
},
|
||||
{
|
||||
title: "템플릿 수",
|
||||
value: stats.totalTemplates,
|
||||
icon: FileText,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
href: "/admin/mail/templates",
|
||||
},
|
||||
{
|
||||
title: "오늘 발송",
|
||||
value: stats.sentToday,
|
||||
icon: Send,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-600",
|
||||
href: "/admin/mail/sent",
|
||||
},
|
||||
{
|
||||
title: "오늘 수신",
|
||||
value: stats.receivedToday,
|
||||
icon: Inbox,
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
href: "/admin/mail/receive",
|
||||
},
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: "계정 관리",
|
||||
description: "메일 계정 설정",
|
||||
href: "/admin/mail/accounts",
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
title: "템플릿 관리",
|
||||
description: "템플릿 편집",
|
||||
href: "/admin/mail/templates",
|
||||
icon: FileText,
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
title: "메일 발송",
|
||||
description: "메일 보내기",
|
||||
href: "/admin/mail/send",
|
||||
icon: Send,
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
title: "대량 발송",
|
||||
description: "CSV로 대량 발송",
|
||||
href: "/admin/mail/bulk-send",
|
||||
icon: Users,
|
||||
color: "teal",
|
||||
},
|
||||
{
|
||||
title: "보낸메일함",
|
||||
description: "발송 이력 확인",
|
||||
href: "/admin/mail/sent",
|
||||
icon: Mail,
|
||||
color: "indigo",
|
||||
},
|
||||
{
|
||||
title: "수신함",
|
||||
description: "받은 메일 확인",
|
||||
href: "/admin/mail/receive",
|
||||
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 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">
|
||||
<div className="p-4 bg-primary/10 rounded-lg">
|
||||
<Mail className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-1">메일 관리 대시보드</h1>
|
||||
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
<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-3">
|
||||
{statCards.map((stat, index) => (
|
||||
<Link key={index} href={stat.href}>
|
||||
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="text-4xl font-bold text-foreground">
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<stat.icon className="w-7 h-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 진행 바 */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-1000"
|
||||
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 이번 달 통계 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
||||
<Calendar className="w-5 h-5 text-foreground" />
|
||||
</div>
|
||||
<span>이번 달 발송 통계</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium text-muted-foreground">총 발송 건수</span>
|
||||
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} 건</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium text-muted-foreground">성공률</span>
|
||||
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span>
|
||||
</div>
|
||||
{/* 전월 대비 통계는 현재 불필요하여 주석처리
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
전월 대비
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">+12%</span>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
||||
<Mail className="w-5 h-5 text-foreground" />
|
||||
</div>
|
||||
<span>시스템 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">메일 서버</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground">정상 작동</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">활성 계정</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} 개</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">사용 가능 템플릿</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} 개</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 빠른 액세스 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg">빠른 액세스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickLinks.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50"
|
||||
>
|
||||
<div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform">
|
||||
<link.icon className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{link.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
frontend/app/(main)/admin/automaticMng/mail/drafts/page.tsx
Normal file
201
frontend/app/(main)/admin/automaticMng/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/automaticMng/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>
|
||||
);
|
||||
}
|
||||
|
||||
1001
frontend/app/(main)/admin/automaticMng/mail/receive/page.tsx
Normal file
1001
frontend/app/(main)/admin/automaticMng/mail/receive/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1559
frontend/app/(main)/admin/automaticMng/mail/send/page.tsx
Normal file
1559
frontend/app/(main)/admin/automaticMng/mail/send/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
736
frontend/app/(main)/admin/automaticMng/mail/sent/page.tsx
Normal file
736
frontend/app/(main)/admin/automaticMng/mail/sent/page.tsx
Normal file
@@ -0,0 +1,736 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Send,
|
||||
Search,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Mail,
|
||||
User,
|
||||
Clock,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
Eye,
|
||||
Reply,
|
||||
Forward,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
SentMailHistory,
|
||||
getSentMailList,
|
||||
deleteSentMail,
|
||||
getMailAccounts,
|
||||
MailAccount,
|
||||
getMailStatistics,
|
||||
} from "@/lib/api/mail";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
export default function SentMailPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
|
||||
// 상태
|
||||
const [mails, setMails] = useState<SentMailHistory[]>([]);
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// 선택된 메일
|
||||
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
||||
const [selectedMailDetail, setSelectedMailDetail] = useState<SentMailHistory | null>(null);
|
||||
|
||||
// 필터
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<"all" | "success" | "failed">("all");
|
||||
const [filterAccountId, setFilterAccountId] = useState<string>("all");
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage] = useState(10);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 통계
|
||||
const [stats, setStats] = useState({
|
||||
totalSent: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
todayCount: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
loadStats();
|
||||
loadMails();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||
loadMails();
|
||||
}, [filterStatus, filterAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMails();
|
||||
}, [currentPage]);
|
||||
|
||||
// URL에서 mailId 파라미터 확인하여 자동 선택
|
||||
useEffect(() => {
|
||||
const mailId = searchParams.get("mailId");
|
||||
if (mailId && mails.length > 0) {
|
||||
const mail = mails.find((m) => m.id === mailId);
|
||||
if (mail) {
|
||||
// console.log("🎯 URL에서 지정된 메일 자동 선택:", mailId);
|
||||
handleMailClick(mail);
|
||||
}
|
||||
}
|
||||
}, [searchParams, mails]);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getMailAccounts();
|
||||
setAccounts(data.filter((acc) => acc.status === "active"));
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error("계정 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await getMailStatistics();
|
||||
setStats({
|
||||
totalSent: data.totalSent || 0,
|
||||
successCount: data.successCount || 0,
|
||||
failedCount: data.failedCount || 0,
|
||||
todayCount: data.todaySent || 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error("통계 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const filters: any = {
|
||||
sortBy: "sentAt",
|
||||
sortOrder: "desc",
|
||||
limit: 1000, // 전체 메일 가져오기 (프론트에서 페이지네이션)
|
||||
};
|
||||
|
||||
// 상태 필터: 'all'이면 status 필터 없음 (draft 제외하려면 success/failed만)
|
||||
if (filterStatus === "all") {
|
||||
// draft를 제외하고 발송된 메일만 (success + failed)
|
||||
// status 필터를 안 넣으면 draft도 포함되므로, 클라이언트에서 필터링
|
||||
} else {
|
||||
filters.status = filterStatus; // 'success' 또는 'failed'
|
||||
}
|
||||
|
||||
if (filterAccountId !== "all") {
|
||||
filters.accountId = filterAccountId;
|
||||
}
|
||||
|
||||
// console.log("📤 발신메일 로드 시작:", filters);
|
||||
const response = await getSentMailList(filters);
|
||||
// console.log("📤 발신메일 응답:", response);
|
||||
|
||||
let mailList = response.items || [];
|
||||
|
||||
// draft 제외 (발송된 메일만 표시)
|
||||
mailList = mailList.filter((mail) => mail.status !== "draft");
|
||||
|
||||
// console.log("📤 발신메일 개수 (draft 제외):", mailList.length);
|
||||
|
||||
// 검색어 필터링 (클라이언트 사이드)
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
mailList = mailList.filter(
|
||||
(mail) =>
|
||||
mail.subject?.toLowerCase().includes(term) ||
|
||||
mail.to?.some((email) => email.toLowerCase().includes(term)) ||
|
||||
mail.accountEmail?.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션 적용
|
||||
const totalItems = mailList.length;
|
||||
const totalPagesCalc = Math.ceil(totalItems / itemsPerPage);
|
||||
setTotalPages(totalPagesCalc);
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedMails = mailList.slice(startIndex, endIndex);
|
||||
|
||||
setMails(paginatedMails);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error("❌ 메일 로드 실패:", err);
|
||||
toast({
|
||||
title: "메일 로드 실패",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMailClick = (mail: SentMailHistory) => {
|
||||
setSelectedMailId(mail.id);
|
||||
setSelectedMailDetail(mail);
|
||||
// console.log("📧 메일 클릭:", mail.id);
|
||||
};
|
||||
|
||||
const handleDeleteMail = async () => {
|
||||
if (!selectedMailId || !confirm("이 메일을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteSentMail(selectedMailId);
|
||||
|
||||
// 메일 목록에서 제거
|
||||
setMails(mails.filter((m) => m.id !== selectedMailId));
|
||||
|
||||
// 상세 패널 닫기
|
||||
setSelectedMailId("");
|
||||
setSelectedMailDetail(null);
|
||||
|
||||
toast({
|
||||
title: "메일 삭제 완료",
|
||||
description: "메일이 휴지통으로 이동되었습니다.",
|
||||
});
|
||||
|
||||
// 통계 새로고침
|
||||
loadStats();
|
||||
} catch (error: any) {
|
||||
console.error("메일 삭제 실패:", error);
|
||||
toast({
|
||||
title: "메일 삭제 실패",
|
||||
description: error.response?.data?.message || error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
if (!selectedMailDetail) return;
|
||||
|
||||
// 원본 수신자를 받는사람으로 설정
|
||||
const toEmail = selectedMailDetail.to?.[0] || "";
|
||||
|
||||
const replyData = {
|
||||
originalFrom: selectedMailDetail.accountEmail,
|
||||
originalTo: toEmail,
|
||||
originalSubject: selectedMailDetail.subject,
|
||||
originalDate: selectedMailDetail.sentAt,
|
||||
originalBody: selectedMailDetail.htmlContent || "",
|
||||
};
|
||||
|
||||
router.push(
|
||||
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
if (!selectedMailDetail) return;
|
||||
|
||||
const forwardData = {
|
||||
originalFrom: selectedMailDetail.accountEmail,
|
||||
originalSubject: selectedMailDetail.subject,
|
||||
originalDate: selectedMailDetail.sentAt,
|
||||
originalBody: selectedMailDetail.htmlContent || "",
|
||||
};
|
||||
|
||||
router.push(
|
||||
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (hours < 24) {
|
||||
return date.toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleDateString("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 메일 개수
|
||||
const filteredCount = mails.length;
|
||||
|
||||
if (loading && mails.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">메일을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">보낸메일함</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 및 빠른 액션 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<Send className="w-8 h-8" />
|
||||
보낸메일함
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
총 {filteredCount}개의 발송 메일
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={loadMails} variant="outline" size="sm" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
메일 작성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">전체 발송</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{stats.totalSent}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-primary/10 rounded-lg">
|
||||
<Send className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">발송 성공</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{stats.successCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/10 rounded-lg">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">발송 실패</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{stats.failedCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-500/10 rounded-lg">
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">오늘 발송</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{stats.todayCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
||||
<Calendar className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* 검색 */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="제목, 받는사람, 계정으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={filterStatus} onValueChange={(value: any) => setFilterStatus(value)}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="success">발송 성공</SelectItem>
|
||||
<SelectItem value="failed">발송 실패</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 계정 필터 */}
|
||||
<Select
|
||||
value={filterAccountId}
|
||||
onValueChange={(value) => setFilterAccountId(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[200px]">
|
||||
<SelectValue placeholder="발송 계정" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 계정</SelectItem>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
{account.name} ({account.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메일 목록 + 상세보기 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 왼쪽: 메일 목록 */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="flex flex-col h-[calc(100vh-500px)] min-h-[400px]">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-base">메일 목록</CardTitle>
|
||||
<CardDescription>{filteredCount}개의 메일</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
{mails.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
발송된 메일이 없습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
mails.map((mail) => (
|
||||
<div
|
||||
key={mail.id}
|
||||
onClick={() => handleMailClick(mail)}
|
||||
className={`
|
||||
p-4 border-b cursor-pointer transition-colors
|
||||
hover:bg-accent
|
||||
${selectedMailId === mail.id ? "bg-accent" : ""}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{mail.to?.[0] || "받는사람 없음"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{mail.subject || "(제목 없음)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(mail.sentAt)}
|
||||
</span>
|
||||
{mail.status === "success" ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
성공
|
||||
</Badge>
|
||||
) : mail.status === "failed" ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
실패
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span className="truncate">{mail.accountEmail}</span>
|
||||
{mail.attachments && mail.attachments.length > 0 && (
|
||||
<>
|
||||
<Paperclip className="w-3 h-3 ml-2" />
|
||||
<span>{mail.attachments.length}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
처음
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
마지막
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 메일 상세보기 */}
|
||||
<div className="lg:col-span-2">
|
||||
{selectedMailDetail ? (
|
||||
<Card className="flex flex-col h-[calc(100vh-500px)] min-h-[400px]">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-xl mb-2">
|
||||
{selectedMailDetail.subject || "(제목 없음)"}
|
||||
</CardTitle>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">보낸사람:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{selectedMailDetail.accountName} ({selectedMailDetail.accountEmail})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">받는사람:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{selectedMailDetail.to?.join(", ") || "-"}
|
||||
</span>
|
||||
</div>
|
||||
{selectedMailDetail.cc && selectedMailDetail.cc.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">참조:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{selectedMailDetail.cc.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">발송일시:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(selectedMailDetail.sentAt).toLocaleString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 답장/전달/삭제 버튼 */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" onClick={handleReply}>
|
||||
<Reply className="w-4 h-4 mr-1" />
|
||||
답장
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleForward}>
|
||||
<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>
|
||||
|
||||
<Separator className="flex-shrink-0" />
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto pt-6">
|
||||
{/* 첨부파일 */}
|
||||
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
첨부파일 ({selectedMailDetail.attachments.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedMailDetail.attachments.map((file: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 text-sm bg-background p-2 rounded"
|
||||
>
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{file.filename || file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{file.size ? `${(file.size / 1024).toFixed(1)}KB` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메일 본문 */}
|
||||
{selectedMailDetail.htmlContent ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(selectedMailDetail.htmlContent),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap text-sm">
|
||||
{selectedMailDetail.htmlContent || "(내용 없음)"}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-[calc(100vh-500px)] min-h-[400px]">
|
||||
<CardContent className="flex flex-col items-center justify-center h-full">
|
||||
<Eye className="w-16 h-16 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
메일을 선택하면 내용이 표시됩니다
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
frontend/app/(main)/admin/automaticMng/mail/templates/page.tsx
Normal file
307
frontend/app/(main)/admin/automaticMng/mail/templates/page.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, FileText, Loader2, RefreshCw, Search, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
MailTemplate,
|
||||
getMailTemplates,
|
||||
createMailTemplate,
|
||||
updateMailTemplate,
|
||||
deleteMailTemplate,
|
||||
CreateMailTemplateDto,
|
||||
UpdateMailTemplateDto,
|
||||
} from "@/lib/api/mail";
|
||||
import MailTemplateCard from "@/components/mail/MailTemplateCard";
|
||||
import MailTemplatePreviewModal from "@/components/mail/MailTemplatePreviewModal";
|
||||
import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
|
||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailTemplatesPage() {
|
||||
const router = useRouter();
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
|
||||
// 모달 상태
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null);
|
||||
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
// 템플릿 목록 불러오기
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMailTemplates();
|
||||
setTemplates(data);
|
||||
} catch (error) {
|
||||
// console.error('템플릿 로드 실패:', error);
|
||||
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
// 필터링된 템플릿
|
||||
const filteredTemplates = templates.filter((template) => {
|
||||
const matchesSearch =
|
||||
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
template.subject.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory =
|
||||
categoryFilter === 'all' || template.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
// 카테고리 목록 추출
|
||||
const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditorMode('create');
|
||||
setSelectedTemplate(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (template: MailTemplate) => {
|
||||
setEditorMode('edit');
|
||||
setSelectedTemplate(template);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenPreviewModal = (template: MailTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (template: MailTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => {
|
||||
try {
|
||||
if (editorMode === 'create') {
|
||||
await createMailTemplate(data as CreateMailTemplateDto);
|
||||
} else if (editorMode === 'edit' && selectedTemplate) {
|
||||
await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto);
|
||||
}
|
||||
await loadTemplates();
|
||||
setIsEditorOpen(false);
|
||||
} catch (error) {
|
||||
throw error; // 모달에서 에러 처리
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
try {
|
||||
await deleteMailTemplate(selectedTemplate.id);
|
||||
await loadTemplates();
|
||||
alert('템플릿이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
// console.error('템플릿 삭제 실패:', error);
|
||||
alert('템플릿 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateTemplate = async (template: MailTemplate) => {
|
||||
try {
|
||||
await createMailTemplate({
|
||||
name: `${template.name} (복사본)`,
|
||||
subject: template.subject,
|
||||
components: template.components,
|
||||
category: template.category,
|
||||
});
|
||||
await loadTemplates();
|
||||
alert('템플릿이 복사되었습니다.');
|
||||
} catch (error) {
|
||||
// console.error('템플릿 복사 실패:', error);
|
||||
alert('템플릿 복사에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">템플릿 관리</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 + 액션 버튼들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">메일 템플릿 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTemplates}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 템플릿 만들기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="템플릿 이름, 제목으로 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
||||
>
|
||||
<option value="all">전체 카테고리</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<Card className="text-center py-16">
|
||||
<CardContent className="pt-6">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{templates.length === 0
|
||||
? '아직 생성된 템플릿이 없습니다'
|
||||
: '검색 결과가 없습니다'}
|
||||
</p>
|
||||
{templates.length === 0 && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
첫 템플릿 만들기
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map((template) => (
|
||||
<MailTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onPreview={handleOpenPreviewModal}
|
||||
onDuplicate={handleDuplicateTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2 text-foreground" />
|
||||
템플릿 디자이너
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-foreground mb-4">
|
||||
💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>텍스트, 버튼, 이미지, 여백 컴포넌트 지원</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>실시간 미리보기로 즉시 확인 가능</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>동적 변수 지원 (예: {"{customer_name}"})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 모달들 */}
|
||||
<MailTemplateEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSave={handleSaveTemplate}
|
||||
template={selectedTemplate}
|
||||
mode={editorMode}
|
||||
/>
|
||||
|
||||
<MailTemplatePreviewModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
template={selectedTemplate}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteTemplate}
|
||||
title="템플릿 삭제"
|
||||
message="이 템플릿을 삭제하시겠습니까?"
|
||||
itemName={selectedTemplate?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
frontend/app/(main)/admin/automaticMng/mail/trash/page.tsx
Normal file
192
frontend/app/(main)/admin/automaticMng/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