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

382 lines
13 KiB
TypeScript

"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";
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');
if (stored) {
try {
const ids = JSON.parse(stored);
setReadNotificationIds(new Set(ids));
} catch (error) {
console.error('읽은 알림 ID 로드 실패:', error);
}
}
setIsInitialized(true);
}, []);
useEffect(() => {
// localStorage 로드 완료 후에만 알림 로드
if (!isInitialized) return;
// 알림 로드
loadNotifications();
// 5초마다 새 알림 확인 (더 빠른 실시간 업데이트)
const interval = setInterval(() => {
checkNewNotifications();
}, 5000);
// 메일 발송/수신 이벤트 리스너 (즉시 갱신)
const handleMailEvent = () => {
console.log('📧 메일 이벤트 감지 - 알림 갱신');
checkNewNotifications();
};
window.addEventListener('mail-sent', handleMailEvent);
window.addEventListener('mail-received', handleMailEvent);
return () => {
clearInterval(interval);
window.removeEventListener('mail-sent', handleMailEvent);
window.removeEventListener('mail-received', handleMailEvent);
};
}, [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[] = [];
// 1. 최근 발송 실패한 메일 확인 (최근 1시간)
try {
const sentMails = await getSentMailList({
page: 1,
limit: 20,
status: 'failed',
});
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
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(', ')}에게 보낸 메일이 실패했습니다.`,
timestamp: mail.sentAt,
read: false,
url: '/admin/mail/sent', // 보낸메일함으로 이동
});
}
});
} catch (error) {
console.error('발송 실패 메일 확인 오류:', error);
}
// 2. 최근 수신 메일 확인 (최근 30분)
try {
const accounts = await getMailAccounts();
const activeAccounts = accounts.filter((acc) => acc.status === 'active');
for (const account of activeAccounts) {
try {
const receivedMails = await getReceivedMails(account.id, 10);
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
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);
}
// 3. 일일 발송 제한 경고 확인
try {
const accounts = await getMailAccounts();
const sentMails = await getSentMailList({
page: 1,
limit: 100,
});
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;
const usagePercent = (todaySentCount / account.dailyLimit) * 100;
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', // 계정 관리로 이동
});
}
}
});
} catch (error) {
console.error('일일 제한 확인 오류:', error);
}
// 최신순 정렬
newNotifications.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// 읽은 알림 표시 적용
const notificationsWithReadStatus = newNotifications.map((notification) => ({
...notification,
read: readNotificationIds.has(notification.id),
}));
setNotifications(notificationsWithReadStatus);
} catch (error) {
console.error('알림 로드 실패:', error);
}
};
const checkNewNotifications = () => {
loadNotifications();
};
const markAsRead = (id: string) => {
const newReadIds = new Set(readNotificationIds);
newReadIds.add(id);
setReadNotificationIds(newReadIds);
// localStorage에 저장
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(newReadIds)));
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
};
const handleNotificationClick = (notification: MailNotification) => {
// 읽음 처리
markAsRead(notification.id);
// URL이 있으면 해당 페이지로 이동
if (notification.url) {
router.push(notification.url);
setIsOpen(false); // 팝오버 닫기
}
};
const markAllAsRead = () => {
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
setReadNotificationIds(allIds);
// localStorage에 저장
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(allIds)));
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)));
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 -right-1 -top-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-base"></h3>
<div className="flex gap-2">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={markAllAsRead}
className="text-xs"
>
</Button>
)}
{notifications.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="text-xs"
>
</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="h-12 w-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
<div className="divide-y">
{notifications.filter((n) => !n.read).map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-muted/50 transition-colors cursor-pointer ${
!notification.read ? "bg-muted/30" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex items-start gap-3">
<div
className={`p-2 rounded-lg ${getTypeColor(notification.type)}`}
>
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="font-medium text-sm truncate">
{notification.title}
</h4>
{!notification.read && (
<div className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(notification.timestamp).toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}