382 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|