iloom-flatten/src/components/NotificationCenter.tsx

285 lines
12 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../supabase/client';
import { useAuth } from '../contexts/AuthContext';
interface Notification {
id: string;
type: 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system';
title: string;
content: string;
is_read: boolean;
created_at: string;
plan_id?: string | null;
sender_company_name?: string;
}
const typeConfig: Record<string, { icon: any; color: string; bgColor: string }> = {
step_completed: { icon: Check, color: 'text-emerald-600', bgColor: 'bg-emerald-50' },
plan_created: { icon: FileText, color: 'text-blue-600', bgColor: 'bg-blue-50' },
plan_updated: { icon: AlertCircle, color: 'text-amber-600', bgColor: 'bg-amber-50' },
plan_confirmed: { icon: Check, color: 'text-emerald-600', bgColor: 'bg-emerald-50' },
plan_changed: { icon: Edit3, color: 'text-orange-600', bgColor: 'bg-orange-50' },
plan_cancelled: { icon: X, color: 'text-red-600', bgColor: 'bg-red-50' },
plan_rejected: { icon: AlertCircle, color: 'text-rose-600', bgColor: 'bg-rose-50' },
inventory_in: { icon: Package, color: 'text-indigo-600', bgColor: 'bg-indigo-50' },
payment_pending: { icon: CreditCard, color: 'text-purple-600', bgColor: 'bg-purple-50' },
system: { icon: Bell, color: 'text-gray-600', bgColor: 'bg-gray-50' }
};
interface NotificationCenterProps {
theme?: 'amber' | 'emerald' | 'purple' | 'blue';
}
const themeConfig = {
amber: { bg: 'bg-amber-100', hover: 'hover:bg-amber-200', icon: 'text-amber-700' },
emerald: { bg: 'bg-emerald-100', hover: 'hover:bg-emerald-200', icon: 'text-emerald-700' },
purple: { bg: 'bg-purple-100', hover: 'hover:bg-purple-200', icon: 'text-purple-700' },
blue: { bg: 'bg-blue-100', hover: 'hover:bg-blue-200', icon: 'text-blue-700' },
};
export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps = {}) {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const { auth } = useAuth();
const navigate = useNavigate();
const colors = themeConfig[theme];
useEffect(() => {
if (!auth.user) return;
fetchNotifications();
subscribeToNotifications();
}, [auth.user]);
const fetchNotifications = async () => {
const { data } = await supabase
.from('notifications')
.select('*, sender_company_id(name)')
.eq('recipient_id', auth.user!.id)
.order('created_at', { ascending: false })
.limit(20);
if (data) {
const formatted = data.map(n => ({
id: n.id,
type: n.type as Notification['type'],
title: n.title,
content: n.content,
is_read: n.is_read || false,
created_at: n.created_at || '',
plan_id: n.plan_id,
sender_company_name: n.sender_company_id?.name
}));
setNotifications(formatted);
setUnreadCount(formatted.filter(n => !n.is_read).length);
}
};
const subscribeToNotifications = () => {
supabase
.channel('notifications')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${auth.user!.id}`
}, () => {
fetchNotifications();
})
.subscribe();
};
const markAsRead = async (id: string) => {
await supabase
.from('notifications')
.update({ is_read: true, read_at: new Date().toISOString() })
.eq('id', id);
setNotifications(prev => prev.map(n =>
n.id === id ? { ...n, is_read: true } : n
));
setUnreadCount(prev => Math.max(0, prev - 1));
};
const markAllAsRead = async () => {
await supabase
.from('notifications')
.update({ is_read: true, read_at: new Date().toISOString() })
.eq('recipient_id', auth.user!.id)
.eq('is_read', false);
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
setUnreadCount(0);
};
const formatTime = (date: string) => {
const d = new Date(date);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return d.toLocaleDateString('zh-CN');
};
return (
<>
{/* 通知按钮 */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(true)}
className={`relative flex items-center justify-center w-10 h-10 rounded-xl ${colors.bg} ${colors.hover} transition-colors`}
>
<Bell className={`w-5 h-5 ${colors.icon} flex-shrink-0`} strokeWidth={2} />
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center border-2 border-white"
>
{unreadCount > 99 ? '99+' : unreadCount}
</motion.span>
)}
</motion.button>
{/* 通知弹窗 */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-start justify-end p-4"
onClick={() => setIsOpen(false)}
>
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden mt-16 mr-4"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-amber-500" />
<h2 className="font-semibold text-gray-900"></h2>
{unreadCount > 0 && (
<span className="px-2 py-0.5 bg-red-100 text-red-600 text-xs rounded-full">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<CheckCheck className="w-3 h-3" />
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
</div>
{/* 通知列表 */}
<div className="overflow-y-auto max-h-[60vh]">
{notifications.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<Bell className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p></p>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map((notification, index) => {
const config = typeConfig[notification.type] || typeConfig.system;
const Icon = config.icon;
const handleNotificationClick = async () => {
await markAsRead(notification.id);
// 如果有 plan_id跳转到计划总览页面
if (notification.plan_id) {
setIsOpen(false);
// 根据用户角色跳转到对应页面
const role = auth.company?.role;
if (role === 'purchaser') {
navigate('/purchaser/plans');
} else if (role === 'textile') {
navigate('/textile/plans');
} else if (role === 'washing') {
navigate('/washing/plans');
}
}
};
return (
<motion.div
key={notification.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
onClick={handleNotificationClick}
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
!notification.is_read ? 'bg-blue-50/30' : ''
}`}
>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${config.bgColor}`}>
<Icon className={`w-5 h-5 ${config.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h3 className={`font-medium text-sm ${!notification.is_read ? 'text-gray-900' : 'text-gray-600'}`}>
{notification.title}
</h3>
{!notification.is_read && (
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1.5" />
)}
</div>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{notification.content}
</p>
<div className="flex items-center gap-2 mt-2">
<Clock className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-400">
{formatTime(notification.created_at)}
</span>
{notification.sender_company_name && (
<span className="text-xs text-gray-400">
· {notification.sender_company_name}
</span>
)}
</div>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}