ERP-node/frontend/components/mail/MailNotifications.tsx

353 lines
12 KiB
TypeScript
Raw Normal View History

2025-10-22 16:06:04 +09:00
"use client";
import React, { useState, useEffect } from "react";
import { Bell, Mail, AlertCircle, XCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
2025-10-22 16:06:04 +09:00
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { getSentMailList, getReceivedMails, getMailAccounts } from "@/lib/api/mail";
import { useRouter } from "next/navigation";
interface MailNotification {
id: string;
type: "new_mail" | "send_failed" | "limit_warning";
title: string;
message: string;
timestamp: string;
read: boolean;
url?: string; // 이동할 URL 추가
}
export default function MailNotifications() {
const router = useRouter();
const [notifications, setNotifications] = useState<MailNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [readNotificationIds, setReadNotificationIds] = useState<Set<string>>(new Set());
const [isInitialized, setIsInitialized] = useState(false);
// localStorage에서 읽은 알림 ID 로드 (최우선)
useEffect(() => {
const stored = localStorage.getItem("readNotificationIds");
2025-10-22 16:06:04 +09:00
if (stored) {
try {
const ids = JSON.parse(stored);
setReadNotificationIds(new Set(ids));
} catch (error) {
console.error("읽은 알림 ID 로드 실패:", error);
2025-10-22 16:06:04 +09:00
}
}
setIsInitialized(true);
}, []);
useEffect(() => {
// localStorage 로드 완료 후에만 알림 로드
if (!isInitialized) return;
// 알림 로드
loadNotifications();
2025-10-22 16:06:04 +09:00
// 5초마다 새 알림 확인 (더 빠른 실시간 업데이트)
const interval = setInterval(() => {
checkNewNotifications();
}, 5000);
// 메일 발송/수신 이벤트 리스너 (즉시 갱신)
const handleMailEvent = () => {
console.log("📧 메일 이벤트 감지 - 알림 갱신");
2025-10-22 16:06:04 +09:00
checkNewNotifications();
};
window.addEventListener("mail-sent", handleMailEvent);
window.addEventListener("mail-received", handleMailEvent);
2025-10-22 16:06:04 +09:00
return () => {
clearInterval(interval);
window.removeEventListener("mail-sent", handleMailEvent);
window.removeEventListener("mail-received", handleMailEvent);
2025-10-22 16:06:04 +09:00
};
}, [isInitialized, readNotificationIds]);
useEffect(() => {
const count = notifications.filter((n) => !n.read).length;
setUnreadCount(count);
}, [notifications]);
// 알림 패널이 열리면 3초 후 자동으로 읽음 처리
useEffect(() => {
if (isOpen && notifications.some((n) => !n.read)) {
const timer = setTimeout(() => {
markAllAsRead();
}, 3000); // 3초 후 자동 읽음 처리
return () => clearTimeout(timer);
}
}, [isOpen, notifications]);
const loadNotifications = async () => {
try {
const newNotifications: MailNotification[] = [];
2025-10-22 16:06:04 +09:00
// 1. 최근 발송 실패한 메일 확인 (최근 1시간)
try {
const sentMails = await getSentMailList({
page: 1,
limit: 20,
status: "failed",
2025-10-22 16:06:04 +09:00
});
2025-10-22 16:06:04 +09:00
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
2025-10-22 16:06:04 +09:00
sentMails.items?.forEach((mail) => {
const sentDate = new Date(mail.sentAt);
if (sentDate > oneHourAgo) {
newNotifications.push({
id: `failed-${mail.id}`,
type: "send_failed",
title: "메일 발송 실패",
message: `${mail.to.join(", ")}에게 보낸 메일이 실패했습니다.`,
2025-10-22 16:06:04 +09:00
timestamp: mail.sentAt,
read: false,
url: "/admin/mail/sent", // 보낸메일함으로 이동
2025-10-22 16:06:04 +09:00
});
}
});
} catch (error) {
console.error("발송 실패 메일 확인 오류:", error);
2025-10-22 16:06:04 +09:00
}
2025-10-22 16:06:04 +09:00
// 2. 최근 수신 메일 확인 (최근 30분)
try {
const accounts = await getMailAccounts();
const activeAccounts = accounts.filter((acc) => acc.status === "active");
2025-10-22 16:06:04 +09:00
for (const account of activeAccounts) {
try {
const receivedMails = await getReceivedMails(account.id, 10);
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
2025-10-22 16:06:04 +09:00
receivedMails.forEach((mail) => {
const receivedDate = new Date(mail.date);
if (receivedDate > thirtyMinutesAgo && !mail.isRead) {
newNotifications.push({
id: `new-${mail.id}`,
type: "new_mail",
title: "새 메일 도착",
message: `${mail.from}에서 메일이 도착했습니다: ${mail.subject}`,
timestamp: mail.date,
read: false,
url: `/admin/mail/receive?mailId=${mail.id}&accountId=${account.id}`, // 특정 메일로 이동
});
}
});
} catch (error) {
console.error(`계정 ${account.id} 수신 메일 확인 오류:`, error);
}
}
} catch (error) {
console.error("수신 메일 확인 오류:", error);
2025-10-22 16:06:04 +09:00
}
2025-10-22 16:06:04 +09:00
// 3. 일일 발송 제한 경고 확인
try {
const accounts = await getMailAccounts();
const sentMails = await getSentMailList({
page: 1,
limit: 100,
});
2025-10-22 16:06:04 +09:00
accounts.forEach((account) => {
if (account.status === "active" && account.dailyLimit) {
const todaySentCount =
sentMails.items?.filter((mail) => {
const sentDate = new Date(mail.sentAt);
const today = new Date();
return mail.accountId === account.id && sentDate.toDateString() === today.toDateString();
}).length || 0;
2025-10-22 16:06:04 +09:00
const usagePercent = (todaySentCount / account.dailyLimit) * 100;
2025-10-22 16:06:04 +09:00
if (usagePercent >= 80) {
newNotifications.push({
id: `limit-${account.id}`,
type: "limit_warning",
title: "일일 발송 제한 경고",
message: `${account.name} 계정이 일일 제한의 ${usagePercent.toFixed(0)}%를 사용했습니다 (${todaySentCount}/${account.dailyLimit})`,
timestamp: new Date().toISOString(),
read: false,
url: "/admin/mail/accounts", // 계정 관리로 이동
2025-10-22 16:06:04 +09:00
});
}
}
});
} catch (error) {
console.error("일일 제한 확인 오류:", error);
2025-10-22 16:06:04 +09:00
}
2025-10-22 16:06:04 +09:00
// 최신순 정렬
newNotifications.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
2025-10-22 16:06:04 +09:00
// 읽은 알림 표시 적용
const notificationsWithReadStatus = newNotifications.map((notification) => ({
...notification,
read: readNotificationIds.has(notification.id),
}));
2025-10-22 16:06:04 +09:00
setNotifications(notificationsWithReadStatus);
} catch (error) {
console.error("알림 로드 실패:", error);
2025-10-22 16:06:04 +09:00
}
};
const checkNewNotifications = () => {
loadNotifications();
};
const markAsRead = (id: string) => {
const newReadIds = new Set(readNotificationIds);
newReadIds.add(id);
setReadNotificationIds(newReadIds);
2025-10-22 16:06:04 +09:00
// localStorage에 저장
localStorage.setItem("readNotificationIds", JSON.stringify(Array.from(newReadIds)));
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
2025-10-22 16:06:04 +09:00
};
const handleNotificationClick = (notification: MailNotification) => {
// 읽음 처리
markAsRead(notification.id);
2025-10-22 16:06:04 +09:00
// URL이 있으면 해당 페이지로 이동
if (notification.url) {
router.push(notification.url);
setIsOpen(false); // 팝오버 닫기
}
};
const markAllAsRead = () => {
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
setReadNotificationIds(allIds);
2025-10-22 16:06:04 +09:00
// localStorage에 저장
localStorage.setItem("readNotificationIds", JSON.stringify(Array.from(allIds)));
2025-10-22 16:06:04 +09:00
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
};
const clearAll = () => {
// 모든 알림을 읽음으로 표시하고 localStorage에 저장
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
setReadNotificationIds(allIds);
localStorage.setItem("readNotificationIds", JSON.stringify(Array.from(allIds)));
2025-10-22 16:06:04 +09:00
setNotifications([]);
};
const getIcon = (type: MailNotification["type"]) => {
switch (type) {
case "new_mail":
return <Mail className="h-4 w-4 text-blue-600" />;
case "send_failed":
return <XCircle className="h-4 w-4 text-red-600" />;
case "limit_warning":
return <AlertCircle className="h-4 w-4 text-yellow-600" />;
default:
return <Bell className="h-4 w-4" />;
}
};
const getTypeColor = (type: MailNotification["type"]) => {
switch (type) {
case "new_mail":
return "bg-blue-50 border-blue-200";
case "send_failed":
return "bg-red-50 border-red-200";
case "limit_warning":
return "bg-yellow-50 border-yellow-200";
default:
return "bg-muted";
}
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full p-0 text-xs"
2025-10-22 16:06:04 +09:00
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-base font-semibold"></h3>
2025-10-22 16:06:04 +09:00
<div className="flex gap-2">
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={markAllAsRead} className="text-xs">
2025-10-22 16:06:04 +09:00
</Button>
)}
{notifications.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearAll} className="text-xs">
2025-10-22 16:06:04 +09:00
</Button>
)}
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.filter((n) => !n.read).length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="text-muted-foreground mb-3 h-12 w-12" />
<p className="text-muted-foreground text-sm"> </p>
2025-10-22 16:06:04 +09:00
</div>
) : (
<div className="divide-y">
{notifications
.filter((n) => !n.read)
.map((notification) => (
<div
key={notification.id}
className={`hover:bg-muted/50 cursor-pointer p-4 transition-colors ${
!notification.read ? "bg-muted/30" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex items-start gap-3">
<div className={`rounded-lg p-2 ${getTypeColor(notification.type)}`}>
{getIcon(notification.type)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between">
<h4 className="truncate text-sm font-medium">{notification.title}</h4>
{!notification.read && <div className="h-2 w-2 flex-shrink-0 rounded-full bg-blue-600" />}
</div>
<p className="text-muted-foreground line-clamp-2 text-xs">{notification.message}</p>
<p className="text-muted-foreground mt-1 text-xs">
{new Date(notification.timestamp).toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
2025-10-22 16:06:04 +09:00
</div>
</div>
</div>
))}
2025-10-22 16:06:04 +09:00
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}