ERP-node/frontend/app/(main)/admin/mail/sent/page.tsx

737 lines
26 KiB
TypeScript

"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/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>
);
}