커밋 메세지 메뉴별 대중소 정리

This commit is contained in:
DDD1542
2025-12-29 17:56:26 +09:00
parent 00376202fd
commit 87caa4b3ca
48 changed files with 32 additions and 32 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getSentMailList, updateDraft, deleteSentMail, bulkDeleteMails, type SentMailHistory } from "@/lib/api/mail";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Edit, Trash2, Loader2, Mail } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
export default function DraftsPage() {
const router = useRouter();
const [drafts, setDrafts] = useState<SentMailHistory[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [bulkDeleting, setBulkDeleting] = useState(false);
useEffect(() => {
loadDrafts();
}, []);
const loadDrafts = async () => {
try {
setLoading(true);
const response = await getSentMailList({
status: "draft",
sortBy: "updatedAt",
sortOrder: "desc",
});
// console.log('📋 임시 저장 목록 조회:', response);
// console.log('📋 임시 저장 개수:', response.items.length);
setDrafts(response.items);
} catch (error) {
// console.error("❌ 임시 저장 메일 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleEdit = (draft: SentMailHistory) => {
// 임시 저장 메일을 메일 발송 페이지로 전달
const params = new URLSearchParams({
draftId: draft.id,
to: draft.to.join(","),
cc: draft.cc?.join(",") || "",
bcc: draft.bcc?.join(",") || "",
subject: draft.subject,
content: draft.htmlContent,
accountId: draft.accountId,
});
router.push(`/admin/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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View File

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